mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add AgentAPI using DRPC (#10811)
Co-authored-by: Spike Curtis <spike@coder.com>
This commit is contained in:
co-authored by
Spike Curtis
parent
eb781751b8
commit
e46431078c
@@ -0,0 +1,60 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// ActivityBumpWorkspace automatically bumps the workspace's auto-off timer
|
||||
// if it is set to expire soon. The deadline will be bumped by 1 hour*.
|
||||
// If the bump crosses over an autostart time, the workspace will be
|
||||
// bumped by the workspace ttl instead.
|
||||
//
|
||||
// If nextAutostart is the zero value or in the past, the workspace
|
||||
// will be bumped by 1 hour.
|
||||
// It handles the edge case in the example:
|
||||
// 1. Autostart is set to 9am.
|
||||
// 2. User works all day, and leaves a terminal open to the workspace overnight.
|
||||
// 3. The open terminal continually bumps the workspace deadline.
|
||||
// 4. 9am the next day, the activity bump pushes to 10am.
|
||||
// 5. If the user goes inactive for 1 hour during the day, the workspace will
|
||||
// now stop, because it has been extended by 1 hour durations. Despite the TTL
|
||||
// being set to 8hrs from the autostart time.
|
||||
//
|
||||
// So the issue is that when the workspace is bumped across an autostart
|
||||
// deadline, we should treat the workspace as being "started" again and
|
||||
// extend the deadline by the autostart time + workspace ttl instead.
|
||||
//
|
||||
// The issue still remains with build_max_deadline. We need to respect the original
|
||||
// maximum deadline, so that will need to be handled separately.
|
||||
// A way to avoid this is to configure the max deadline to something that will not
|
||||
// span more than 1 day. This will force the workspace to restart and reset the deadline
|
||||
// each morning when it autostarts.
|
||||
func ActivityBumpWorkspace(ctx context.Context, log slog.Logger, db database.Store, workspaceID uuid.UUID, nextAutostart time.Time) {
|
||||
// We set a short timeout so if the app is under load, these
|
||||
// low priority operations fail first.
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*15)
|
||||
defer cancel()
|
||||
if err := db.ActivityBumpWorkspace(ctx, database.ActivityBumpWorkspaceParams{
|
||||
NextAutostart: nextAutostart.UTC(),
|
||||
WorkspaceID: workspaceID,
|
||||
}); err != nil {
|
||||
if !xerrors.Is(err, context.Canceled) && !database.IsQueryCanceledError(err) {
|
||||
// Bump will fail if the context is canceled, but this is ok.
|
||||
log.Error(ctx, "bump failed", slog.Error(err),
|
||||
slog.F("workspace_id", workspaceID),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "bumped deadline from activity",
|
||||
slog.F("workspace_id", workspaceID),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package agentapi_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/agentapi"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_ActivityBumpWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// We test the below in multiple timezones specifically
|
||||
// chosen to trigger timezone-related bugs.
|
||||
timezones := []string{
|
||||
"Asia/Kolkata", // No DST, positive fractional offset
|
||||
"Canada/Newfoundland", // DST, negative fractional offset
|
||||
"Europe/Paris", // DST, positive offset
|
||||
"US/Arizona", // No DST, negative offset
|
||||
"UTC", // Baseline
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
transition database.WorkspaceTransition
|
||||
jobCompletedAt sql.NullTime
|
||||
buildDeadlineOffset *time.Duration
|
||||
maxDeadlineOffset *time.Duration
|
||||
workspaceTTL time.Duration
|
||||
templateTTL time.Duration
|
||||
templateDisallowsUserAutostop bool
|
||||
expectedBump time.Duration
|
||||
nextAutostart time.Time
|
||||
}{
|
||||
{
|
||||
name: "NotFinishedYet",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{},
|
||||
buildDeadlineOffset: ptr.Ref(8 * time.Hour),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: 0,
|
||||
},
|
||||
{
|
||||
name: "ManualShutdown",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
buildDeadlineOffset: nil,
|
||||
expectedBump: 0,
|
||||
},
|
||||
{
|
||||
name: "NotTimeToBumpYet",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
buildDeadlineOffset: ptr.Ref(8 * time.Hour),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: 0,
|
||||
},
|
||||
{
|
||||
// Expected bump is 0 because the original deadline is more than 1 hour
|
||||
// out, so a bump would decrease the deadline.
|
||||
name: "BumpLessThanDeadline",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
||||
buildDeadlineOffset: ptr.Ref(8*time.Hour - 30*time.Minute),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: 0,
|
||||
},
|
||||
{
|
||||
name: "TimeToBump",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
||||
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: time.Hour,
|
||||
},
|
||||
{
|
||||
name: "TimeToBumpNextAutostart",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
|
||||
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: 8*time.Hour + 30*time.Minute,
|
||||
nextAutostart: time.Now().Add(time.Minute * 30),
|
||||
},
|
||||
{
|
||||
name: "MaxDeadline",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)},
|
||||
buildDeadlineOffset: ptr.Ref(time.Minute), // last chance to bump!
|
||||
maxDeadlineOffset: ptr.Ref(time.Minute * 30),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: time.Minute * 30,
|
||||
},
|
||||
{
|
||||
// A workspace that is still running, has passed its deadline, but has not
|
||||
// yet been auto-stopped should still bump the deadline.
|
||||
name: "PastDeadlineStillBumps",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)},
|
||||
buildDeadlineOffset: ptr.Ref(-time.Minute),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
expectedBump: time.Hour,
|
||||
},
|
||||
{
|
||||
// A stopped workspace should never bump.
|
||||
name: "StoppedWorkspace",
|
||||
transition: database.WorkspaceTransitionStop,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-time.Minute)},
|
||||
buildDeadlineOffset: ptr.Ref(-time.Minute),
|
||||
workspaceTTL: 8 * time.Hour,
|
||||
},
|
||||
{
|
||||
// A workspace built from a template that disallows user autostop should bump
|
||||
// by the template TTL instead.
|
||||
name: "TemplateDisallowsUserAutostop",
|
||||
transition: database.WorkspaceTransitionStart,
|
||||
jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-7 * time.Hour)},
|
||||
buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
|
||||
workspaceTTL: 2 * time.Hour,
|
||||
templateTTL: 10 * time.Hour,
|
||||
templateDisallowsUserAutostop: true,
|
||||
expectedBump: 10*time.Hour + (time.Minute * 30),
|
||||
nextAutostart: time.Now().Add(time.Minute * 30),
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
for _, tz := range timezones {
|
||||
tz := tz
|
||||
t.Run(tt.name+"/"+tz, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
now = dbtime.Now()
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
log = slogtest.Make(t, nil)
|
||||
db, _ = dbtestutil.NewDB(t, dbtestutil.WithTimezone(tz))
|
||||
org = dbgen.Organization(t, db, database.Organization{})
|
||||
user = dbgen.User(t, db, database.User{
|
||||
Status: database.UserStatusActive,
|
||||
})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
template = dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
ActiveVersionID: templateVersion.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
ws = dbgen.Workspace(t, db, database.Workspace{
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
TemplateID: template.ID,
|
||||
Ttl: sql.NullInt64{Valid: true, Int64: int64(tt.workspaceTTL)},
|
||||
})
|
||||
job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
CompletedAt: tt.jobCompletedAt,
|
||||
})
|
||||
_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
|
||||
JobID: job.ID,
|
||||
})
|
||||
buildID = uuid.New()
|
||||
)
|
||||
|
||||
require.NoError(t, db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
|
||||
ID: template.ID,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
AllowUserAutostop: !tt.templateDisallowsUserAutostop,
|
||||
DefaultTTL: int64(tt.templateTTL),
|
||||
}), "unexpected error updating template schedule")
|
||||
|
||||
var buildNumber int32 = 1
|
||||
// Insert a number of previous workspace builds.
|
||||
for i := 0; i < 5; i++ {
|
||||
insertPrevWorkspaceBuild(t, db, org.ID, templateVersion.ID, ws.ID, database.WorkspaceTransitionStart, buildNumber)
|
||||
buildNumber++
|
||||
insertPrevWorkspaceBuild(t, db, org.ID, templateVersion.ID, ws.ID, database.WorkspaceTransitionStop, buildNumber)
|
||||
buildNumber++
|
||||
}
|
||||
|
||||
// dbgen.WorkspaceBuild automatically sets deadline to now+1 hour if not set
|
||||
var buildDeadline time.Time
|
||||
if tt.buildDeadlineOffset != nil {
|
||||
buildDeadline = now.Add(*tt.buildDeadlineOffset)
|
||||
}
|
||||
var maxDeadline time.Time
|
||||
if tt.maxDeadlineOffset != nil {
|
||||
maxDeadline = now.Add(*tt.maxDeadlineOffset)
|
||||
}
|
||||
err := db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
|
||||
ID: buildID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
BuildNumber: buildNumber,
|
||||
InitiatorID: user.ID,
|
||||
Reason: database.BuildReasonInitiator,
|
||||
WorkspaceID: ws.ID,
|
||||
JobID: job.ID,
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
Transition: tt.transition,
|
||||
Deadline: buildDeadline,
|
||||
MaxDeadline: maxDeadline,
|
||||
})
|
||||
require.NoError(t, err, "unexpected error inserting workspace build")
|
||||
bld, err := db.GetWorkspaceBuildByID(ctx, buildID)
|
||||
require.NoError(t, err, "unexpected error fetching inserted workspace build")
|
||||
|
||||
// Validate our initial state before bump
|
||||
require.Equal(t, tt.transition, bld.Transition, "unexpected transition before bump")
|
||||
require.Equal(t, tt.jobCompletedAt.Time.UTC(), job.CompletedAt.Time.UTC(), "unexpected job completed at before bump")
|
||||
require.Equal(t, buildDeadline.UTC(), bld.Deadline.UTC(), "unexpected build deadline before bump")
|
||||
require.Equal(t, maxDeadline.UTC(), bld.MaxDeadline.UTC(), "unexpected max deadline before bump")
|
||||
require.Equal(t, tt.workspaceTTL, time.Duration(ws.Ttl.Int64), "unexpected workspace TTL before bump")
|
||||
|
||||
// Wait a bit before bumping as dbtime is rounded to the nearest millisecond.
|
||||
// This should also hopefully be enough for Windows time resolution to register
|
||||
// a tick (win32 max timer resolution is apparently between 0.5 and 15.6ms)
|
||||
<-time.After(testutil.IntervalFast)
|
||||
|
||||
// Bump duration is measured from the time of the bump, so we measure from here.
|
||||
start := dbtime.Now()
|
||||
agentapi.ActivityBumpWorkspace(ctx, log, db, bld.WorkspaceID, tt.nextAutostart)
|
||||
end := dbtime.Now()
|
||||
|
||||
// Validate our state after bump
|
||||
updatedBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, bld.WorkspaceID)
|
||||
require.NoError(t, err, "unexpected error getting latest workspace build")
|
||||
require.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "max_deadline should not have changed")
|
||||
if tt.expectedBump == 0 {
|
||||
assert.Equal(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should not have bumped updated_at")
|
||||
assert.Equal(t, bld.Deadline.UTC(), updatedBuild.Deadline.UTC(), "should not have bumped deadline")
|
||||
return
|
||||
}
|
||||
assert.NotEqual(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should have bumped updated_at")
|
||||
if tt.maxDeadlineOffset != nil {
|
||||
assert.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "new deadline must equal original max deadline")
|
||||
return
|
||||
}
|
||||
|
||||
// Assert that the bump occurred between start and end. 1min buffer on either side.
|
||||
expectedDeadlineStart := start.Add(tt.expectedBump).Add(time.Minute * -1)
|
||||
expectedDeadlineEnd := end.Add(tt.expectedBump).Add(time.Minute)
|
||||
require.GreaterOrEqual(t, updatedBuild.Deadline, expectedDeadlineStart, "new deadline should be greater than or equal to start")
|
||||
require.LessOrEqual(t, updatedBuild.Deadline, expectedDeadlineEnd, "new deadline should be less than or equal to end")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func insertPrevWorkspaceBuild(t *testing.T, db database.Store, orgID, tvID, workspaceID uuid.UUID, transition database.WorkspaceTransition, buildNumber int32) {
|
||||
t.Helper()
|
||||
|
||||
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
OrganizationID: orgID,
|
||||
})
|
||||
_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
|
||||
JobID: job.ID,
|
||||
})
|
||||
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
BuildNumber: buildNumber,
|
||||
WorkspaceID: workspaceID,
|
||||
JobID: job.ID,
|
||||
TemplateVersionID: tvID,
|
||||
Transition: transition,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc/drpcmux"
|
||||
"storj.io/drpc/drpcserver"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/batchstats"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
||||
"github.com/coder/coder/v2/coderd/schedule"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
const AgentAPIVersionDRPC = "2.0"
|
||||
|
||||
// API implements the DRPC agent API interface from agent/proto. This struct is
|
||||
// instantiated once per agent connection and kept alive for the duration of the
|
||||
// session.
|
||||
type API struct {
|
||||
opts Options
|
||||
*ManifestAPI
|
||||
*ServiceBannerAPI
|
||||
*StatsAPI
|
||||
*LifecycleAPI
|
||||
*AppsAPI
|
||||
*MetadataAPI
|
||||
*LogsAPI
|
||||
*TailnetAPI
|
||||
|
||||
mu sync.Mutex
|
||||
cachedWorkspaceID uuid.UUID
|
||||
}
|
||||
|
||||
var _ agentproto.DRPCAgentServer = &API{}
|
||||
|
||||
type Options struct {
|
||||
AgentID uuid.UUID
|
||||
|
||||
Ctx context.Context
|
||||
Log slog.Logger
|
||||
Database database.Store
|
||||
Pubsub pubsub.Pubsub
|
||||
DerpMapFn func() *tailcfg.DERPMap
|
||||
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
StatsBatcher *batchstats.Batcher
|
||||
PublishWorkspaceUpdateFn func(ctx context.Context, workspaceID uuid.UUID)
|
||||
PublishWorkspaceAgentLogsUpdateFn func(ctx context.Context, workspaceAgentID uuid.UUID, msg agentsdk.LogsNotifyMessage)
|
||||
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
AgentInactiveDisconnectTimeout time.Duration
|
||||
AgentFallbackTroubleshootingURL string
|
||||
AgentStatsRefreshInterval time.Duration
|
||||
DisableDirectConnections bool
|
||||
DerpForceWebSockets bool
|
||||
DerpMapUpdateFrequency time.Duration
|
||||
ExternalAuthConfigs []*externalauth.Config
|
||||
|
||||
// Optional:
|
||||
// WorkspaceID avoids a future lookup to find the workspace ID by setting
|
||||
// the cache in advance.
|
||||
WorkspaceID uuid.UUID
|
||||
UpdateAgentMetricsFn func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric)
|
||||
}
|
||||
|
||||
func New(opts Options) *API {
|
||||
api := &API{
|
||||
opts: opts,
|
||||
mu: sync.Mutex{},
|
||||
cachedWorkspaceID: opts.WorkspaceID,
|
||||
}
|
||||
|
||||
api.ManifestAPI = &ManifestAPI{
|
||||
AccessURL: opts.AccessURL,
|
||||
AppHostname: opts.AppHostname,
|
||||
AgentInactiveDisconnectTimeout: opts.AgentInactiveDisconnectTimeout,
|
||||
AgentFallbackTroubleshootingURL: opts.AgentFallbackTroubleshootingURL,
|
||||
ExternalAuthConfigs: opts.ExternalAuthConfigs,
|
||||
DisableDirectConnections: opts.DisableDirectConnections,
|
||||
DerpForceWebSockets: opts.DerpForceWebSockets,
|
||||
AgentFn: api.agent,
|
||||
Database: opts.Database,
|
||||
DerpMapFn: opts.DerpMapFn,
|
||||
TailnetCoordinator: opts.TailnetCoordinator,
|
||||
}
|
||||
|
||||
api.ServiceBannerAPI = &ServiceBannerAPI{
|
||||
Database: opts.Database,
|
||||
}
|
||||
|
||||
api.StatsAPI = &StatsAPI{
|
||||
AgentFn: api.agent,
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
StatsBatcher: opts.StatsBatcher,
|
||||
TemplateScheduleStore: opts.TemplateScheduleStore,
|
||||
AgentStatsRefreshInterval: opts.AgentStatsRefreshInterval,
|
||||
UpdateAgentMetricsFn: opts.UpdateAgentMetricsFn,
|
||||
}
|
||||
|
||||
api.LifecycleAPI = &LifecycleAPI{
|
||||
AgentFn: api.agent,
|
||||
WorkspaceIDFn: api.workspaceID,
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
PublishWorkspaceUpdateFn: api.publishWorkspaceUpdate,
|
||||
}
|
||||
|
||||
api.AppsAPI = &AppsAPI{
|
||||
AgentFn: api.agent,
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
PublishWorkspaceUpdateFn: api.publishWorkspaceUpdate,
|
||||
}
|
||||
|
||||
api.MetadataAPI = &MetadataAPI{
|
||||
AgentFn: api.agent,
|
||||
Database: opts.Database,
|
||||
Pubsub: opts.Pubsub,
|
||||
Log: opts.Log,
|
||||
}
|
||||
|
||||
api.LogsAPI = &LogsAPI{
|
||||
AgentFn: api.agent,
|
||||
Database: opts.Database,
|
||||
Log: opts.Log,
|
||||
PublishWorkspaceUpdateFn: api.publishWorkspaceUpdate,
|
||||
PublishWorkspaceAgentLogsUpdateFn: opts.PublishWorkspaceAgentLogsUpdateFn,
|
||||
}
|
||||
|
||||
api.TailnetAPI = &TailnetAPI{
|
||||
Ctx: opts.Ctx,
|
||||
DerpMapFn: opts.DerpMapFn,
|
||||
DerpMapUpdateFrequency: opts.DerpMapUpdateFrequency,
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
func (a *API) Server(ctx context.Context) (*drpcserver.Server, error) {
|
||||
mux := drpcmux.New()
|
||||
err := agentproto.DRPCRegisterAgent(mux, a)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("register agent API protocol in DRPC mux: %w", err)
|
||||
}
|
||||
|
||||
return drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux},
|
||||
drpcserver.Options{
|
||||
Log: func(err error) {
|
||||
if xerrors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
a.opts.Log.Debug(ctx, "drpc server error", slog.Error(err))
|
||||
},
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (a *API) Serve(ctx context.Context, l net.Listener) error {
|
||||
server, err := a.Server(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create agent API server: %w", err)
|
||||
}
|
||||
|
||||
return server.Serve(ctx, l)
|
||||
}
|
||||
|
||||
func (a *API) agent(ctx context.Context) (database.WorkspaceAgent, error) {
|
||||
agent, err := a.opts.Database.GetWorkspaceAgentByID(ctx, a.opts.AgentID)
|
||||
if err != nil {
|
||||
return database.WorkspaceAgent{}, xerrors.Errorf("get workspace agent by id %q: %w", a.opts.AgentID, err)
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (a *API) workspaceID(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) {
|
||||
a.mu.Lock()
|
||||
if a.cachedWorkspaceID != uuid.Nil {
|
||||
id := a.cachedWorkspaceID
|
||||
a.mu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
if agent == nil {
|
||||
agnt, err := a.agent(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
agent = &agnt
|
||||
}
|
||||
|
||||
resource, err := a.opts.Database.GetWorkspaceResourceByID(ctx, agent.ResourceID)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("get workspace agent resource by id %q: %w", agent.ResourceID, err)
|
||||
}
|
||||
|
||||
build, err := a.opts.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("get workspace build by job id %q: %w", resource.JobID, err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.cachedWorkspaceID = build.WorkspaceID
|
||||
a.mu.Unlock()
|
||||
return build.WorkspaceID, nil
|
||||
}
|
||||
|
||||
func (a *API) publishWorkspaceUpdate(ctx context.Context, agent *database.WorkspaceAgent) error {
|
||||
workspaceID, err := a.workspaceID(ctx, agent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.opts.PublishWorkspaceUpdateFn(ctx, workspaceID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
type AppsAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
PublishWorkspaceUpdateFn func(context.Context, *database.WorkspaceAgent) error
|
||||
}
|
||||
|
||||
func (a *AppsAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.BatchUpdateAppHealthRequest) (*agentproto.BatchUpdateAppHealthResponse, error) {
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(req.Updates) == 0 {
|
||||
return &agentproto.BatchUpdateAppHealthResponse{}, nil
|
||||
}
|
||||
|
||||
apps, err := a.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace apps by agent ID %q: %w", workspaceAgent.ID, err)
|
||||
}
|
||||
|
||||
var newApps []database.WorkspaceApp
|
||||
for _, update := range req.Updates {
|
||||
updateID, err := uuid.FromBytes(update.Id)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse workspace app ID %q: %w", update.Id, err)
|
||||
}
|
||||
|
||||
old := func() *database.WorkspaceApp {
|
||||
for _, app := range apps {
|
||||
if app.ID == updateID {
|
||||
return &app
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if old == nil {
|
||||
return nil, xerrors.Errorf("workspace app ID %q not found", updateID)
|
||||
}
|
||||
|
||||
if old.HealthcheckUrl == "" {
|
||||
return nil, xerrors.Errorf("workspace app %q (%q) does not have healthchecks enabled", updateID, old.Slug)
|
||||
}
|
||||
|
||||
var newHealth database.WorkspaceAppHealth
|
||||
switch update.Health {
|
||||
case agentproto.AppHealth_DISABLED:
|
||||
newHealth = database.WorkspaceAppHealthDisabled
|
||||
case agentproto.AppHealth_INITIALIZING:
|
||||
newHealth = database.WorkspaceAppHealthInitializing
|
||||
case agentproto.AppHealth_HEALTHY:
|
||||
newHealth = database.WorkspaceAppHealthHealthy
|
||||
case agentproto.AppHealth_UNHEALTHY:
|
||||
newHealth = database.WorkspaceAppHealthUnhealthy
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown health status %q for app %q (%q)", update.Health, updateID, old.Slug)
|
||||
}
|
||||
|
||||
// Don't bother updating if the value hasn't changed.
|
||||
if old.Health == newHealth {
|
||||
continue
|
||||
}
|
||||
old.Health = newHealth
|
||||
|
||||
newApps = append(newApps, *old)
|
||||
}
|
||||
|
||||
for _, app := range newApps {
|
||||
err = a.Database.UpdateWorkspaceAppHealthByID(ctx, database.UpdateWorkspaceAppHealthByIDParams{
|
||||
ID: app.ID,
|
||||
Health: app.Health,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace app health for app %q (%q): %w", err, app.ID, app.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
err = a.PublishWorkspaceUpdateFn(ctx, &workspaceAgent)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("publish workspace update: %w", err)
|
||||
}
|
||||
return &agentproto.BatchUpdateAppHealthResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
)
|
||||
|
||||
type LifecycleAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
WorkspaceIDFn func(context.Context, *database.WorkspaceAgent) (uuid.UUID, error)
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
PublishWorkspaceUpdateFn func(context.Context, *database.WorkspaceAgent) error
|
||||
}
|
||||
|
||||
func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.UpdateLifecycleRequest) (*agentproto.Lifecycle, error) {
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceID, err := a.WorkspaceIDFn(ctx, &workspaceAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger := a.Log.With(
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.F("payload", req),
|
||||
)
|
||||
logger.Debug(ctx, "workspace agent state report")
|
||||
|
||||
var lifecycleState database.WorkspaceAgentLifecycleState
|
||||
switch req.Lifecycle.State {
|
||||
case agentproto.Lifecycle_CREATED:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateCreated
|
||||
case agentproto.Lifecycle_STARTING:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateStarting
|
||||
case agentproto.Lifecycle_START_TIMEOUT:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateStartTimeout
|
||||
case agentproto.Lifecycle_START_ERROR:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateStartError
|
||||
case agentproto.Lifecycle_READY:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateReady
|
||||
case agentproto.Lifecycle_SHUTTING_DOWN:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateShuttingDown
|
||||
case agentproto.Lifecycle_SHUTDOWN_TIMEOUT:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateShutdownTimeout
|
||||
case agentproto.Lifecycle_SHUTDOWN_ERROR:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateShutdownError
|
||||
case agentproto.Lifecycle_OFF:
|
||||
lifecycleState = database.WorkspaceAgentLifecycleStateOff
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown lifecycle state %q", req.Lifecycle.State)
|
||||
}
|
||||
if !lifecycleState.Valid() {
|
||||
return nil, xerrors.Errorf("unknown lifecycle state %q", req.Lifecycle.State)
|
||||
}
|
||||
|
||||
changedAt := req.Lifecycle.ChangedAt.AsTime()
|
||||
if changedAt.IsZero() {
|
||||
changedAt = dbtime.Now()
|
||||
req.Lifecycle.ChangedAt = timestamppb.New(changedAt)
|
||||
}
|
||||
dbChangedAt := sql.NullTime{Time: changedAt, Valid: true}
|
||||
|
||||
startedAt := workspaceAgent.StartedAt
|
||||
readyAt := workspaceAgent.ReadyAt
|
||||
switch lifecycleState {
|
||||
case database.WorkspaceAgentLifecycleStateStarting:
|
||||
startedAt = dbChangedAt
|
||||
readyAt.Valid = false // This agent is re-starting, so it's not ready yet.
|
||||
case database.WorkspaceAgentLifecycleStateReady, database.WorkspaceAgentLifecycleStateStartError:
|
||||
readyAt = dbChangedAt
|
||||
}
|
||||
|
||||
err = a.Database.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
|
||||
ID: workspaceAgent.ID,
|
||||
LifecycleState: lifecycleState,
|
||||
StartedAt: startedAt,
|
||||
ReadyAt: readyAt,
|
||||
})
|
||||
if err != nil {
|
||||
if !xerrors.Is(err, context.Canceled) {
|
||||
// not an error if we are canceled
|
||||
logger.Error(ctx, "failed to update lifecycle state", slog.Error(err))
|
||||
}
|
||||
return nil, xerrors.Errorf("update workspace agent lifecycle state: %w", err)
|
||||
}
|
||||
|
||||
err = a.PublishWorkspaceUpdateFn(ctx, &workspaceAgent)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("publish workspace update: %w", err)
|
||||
}
|
||||
|
||||
return req.Lifecycle, nil
|
||||
}
|
||||
|
||||
func (a *LifecycleAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceID, err := a.WorkspaceIDFn(ctx, &workspaceAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Log.Debug(
|
||||
ctx,
|
||||
"post workspace agent version",
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.F("agent_version", req.Startup.Version),
|
||||
)
|
||||
|
||||
if !semver.IsValid(req.Startup.Version) {
|
||||
return nil, xerrors.Errorf("invalid agent semver version %q", req.Startup.Version)
|
||||
}
|
||||
|
||||
// Validate subsystems.
|
||||
dbSubsystems := make([]database.WorkspaceAgentSubsystem, 0, len(req.Startup.Subsystems))
|
||||
seenSubsystems := make(map[database.WorkspaceAgentSubsystem]struct{}, len(req.Startup.Subsystems))
|
||||
for _, s := range req.Startup.Subsystems {
|
||||
var dbSubsystem database.WorkspaceAgentSubsystem
|
||||
switch s {
|
||||
case agentproto.Startup_ENVBOX:
|
||||
dbSubsystem = database.WorkspaceAgentSubsystemEnvbox
|
||||
case agentproto.Startup_ENVBUILDER:
|
||||
dbSubsystem = database.WorkspaceAgentSubsystemEnvbuilder
|
||||
case agentproto.Startup_EXECTRACE:
|
||||
dbSubsystem = database.WorkspaceAgentSubsystemExectrace
|
||||
default:
|
||||
return nil, xerrors.Errorf("invalid agent subsystem %q", s)
|
||||
}
|
||||
|
||||
if _, ok := seenSubsystems[dbSubsystem]; !ok {
|
||||
seenSubsystems[dbSubsystem] = struct{}{}
|
||||
dbSubsystems = append(dbSubsystems, dbSubsystem)
|
||||
}
|
||||
}
|
||||
|
||||
err = a.Database.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{
|
||||
ID: workspaceAgent.ID,
|
||||
Version: req.Startup.Version,
|
||||
ExpandedDirectory: req.Startup.ExpandedDirectory,
|
||||
Subsystems: dbSubsystems,
|
||||
APIVersion: AgentAPIVersionDRPC,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace agent startup in database: %w", err)
|
||||
}
|
||||
|
||||
return req.Startup, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
type LogsAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
PublishWorkspaceUpdateFn func(context.Context, *database.WorkspaceAgent) error
|
||||
PublishWorkspaceAgentLogsUpdateFn func(ctx context.Context, workspaceAgentID uuid.UUID, msg agentsdk.LogsNotifyMessage)
|
||||
}
|
||||
|
||||
func (a *LogsAPI) BatchCreateLogs(ctx context.Context, req *agentproto.BatchCreateLogsRequest) (*agentproto.BatchCreateLogsResponse, error) {
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(req.Logs) == 0 {
|
||||
return &agentproto.BatchCreateLogsResponse{}, nil
|
||||
}
|
||||
logSourceID, err := uuid.FromBytes(req.LogSourceId)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse log source ID %q: %w", req.LogSourceId, err)
|
||||
}
|
||||
|
||||
// This is to support the legacy API where the log source ID was
|
||||
// not provided in the request body. We default to the external
|
||||
// log source in this case.
|
||||
if logSourceID == uuid.Nil {
|
||||
// Use the external log source
|
||||
externalSources, err := a.Database.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{
|
||||
WorkspaceAgentID: workspaceAgent.ID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
ID: []uuid.UUID{agentsdk.ExternalLogSourceID},
|
||||
DisplayName: []string{"External"},
|
||||
Icon: []string{"/emojis/1f310.png"},
|
||||
})
|
||||
if database.IsUniqueViolation(err, database.UniqueWorkspaceAgentLogSourcesPkey) {
|
||||
err = nil
|
||||
logSourceID = agentsdk.ExternalLogSourceID
|
||||
}
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("insert external workspace agent log source: %w", err)
|
||||
}
|
||||
if len(externalSources) == 1 {
|
||||
logSourceID = externalSources[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
output := make([]string, 0)
|
||||
level := make([]database.LogLevel, 0)
|
||||
outputLength := 0
|
||||
for _, logEntry := range req.Logs {
|
||||
output = append(output, logEntry.Output)
|
||||
outputLength += len(logEntry.Output)
|
||||
|
||||
var dbLevel database.LogLevel
|
||||
switch logEntry.Level {
|
||||
case agentproto.Log_TRACE:
|
||||
dbLevel = database.LogLevelTrace
|
||||
case agentproto.Log_DEBUG:
|
||||
dbLevel = database.LogLevelDebug
|
||||
case agentproto.Log_INFO:
|
||||
dbLevel = database.LogLevelInfo
|
||||
case agentproto.Log_WARN:
|
||||
dbLevel = database.LogLevelWarn
|
||||
case agentproto.Log_ERROR:
|
||||
dbLevel = database.LogLevelError
|
||||
default:
|
||||
// Default to "info" to support older clients that didn't have the
|
||||
// level field.
|
||||
dbLevel = database.LogLevelInfo
|
||||
}
|
||||
level = append(level, dbLevel)
|
||||
}
|
||||
|
||||
logs, err := a.Database.InsertWorkspaceAgentLogs(ctx, database.InsertWorkspaceAgentLogsParams{
|
||||
AgentID: workspaceAgent.ID,
|
||||
CreatedAt: dbtime.Now(),
|
||||
Output: output,
|
||||
Level: level,
|
||||
LogSourceID: logSourceID,
|
||||
OutputLength: int32(outputLength),
|
||||
})
|
||||
if err != nil {
|
||||
if !database.IsWorkspaceAgentLogsLimitError(err) {
|
||||
return nil, xerrors.Errorf("insert workspace agent logs: %w", err)
|
||||
}
|
||||
if workspaceAgent.LogsOverflowed {
|
||||
return nil, xerrors.New("workspace agent logs overflowed")
|
||||
}
|
||||
err := a.Database.UpdateWorkspaceAgentLogOverflowByID(ctx, database.UpdateWorkspaceAgentLogOverflowByIDParams{
|
||||
ID: workspaceAgent.ID,
|
||||
LogsOverflowed: true,
|
||||
})
|
||||
if err != nil {
|
||||
// We don't want to return here, because the agent will retry on
|
||||
// failure and this isn't a huge deal. The overflow state is just a
|
||||
// hint to the user that the logs are incomplete.
|
||||
a.Log.Warn(ctx, "failed to update workspace agent log overflow", slog.Error(err))
|
||||
}
|
||||
|
||||
err = a.PublishWorkspaceUpdateFn(ctx, &workspaceAgent)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("publish workspace update: %w", err)
|
||||
}
|
||||
return nil, xerrors.New("workspace agent log limit exceeded")
|
||||
}
|
||||
|
||||
// Publish by the lowest log ID inserted so the log stream will fetch
|
||||
// everything from that point.
|
||||
lowestLogID := logs[0].ID
|
||||
a.PublishWorkspaceAgentLogsUpdateFn(ctx, workspaceAgent.ID, agentsdk.LogsNotifyMessage{
|
||||
CreatedAfter: lowestLogID - 1,
|
||||
})
|
||||
|
||||
if workspaceAgent.LogsLength == 0 {
|
||||
// If these are the first logs being appended, we publish a UI update
|
||||
// to notify the UI that logs are now available.
|
||||
err = a.PublishWorkspaceUpdateFn(ctx, &workspaceAgent)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("publish workspace update: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &agentproto.BatchCreateLogsResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
"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/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
type ManifestAPI struct {
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
AgentInactiveDisconnectTimeout time.Duration
|
||||
AgentFallbackTroubleshootingURL string
|
||||
ExternalAuthConfigs []*externalauth.Config
|
||||
DisableDirectConnections bool
|
||||
DerpForceWebSockets bool
|
||||
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Database database.Store
|
||||
DerpMapFn func() *tailcfg.DERPMap
|
||||
TailnetCoordinator *atomic.Pointer[tailnet.Coordinator]
|
||||
}
|
||||
|
||||
func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiAgent, err := db2sdk.WorkspaceAgent(
|
||||
a.DerpMapFn(), *a.TailnetCoordinator.Load(), workspaceAgent, nil, nil, nil, a.AgentInactiveDisconnectTimeout,
|
||||
a.AgentFallbackTroubleshootingURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("converting workspace agent: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
dbApps []database.WorkspaceApp
|
||||
scripts []database.WorkspaceAgentScript
|
||||
metadata []database.WorkspaceAgentMetadatum
|
||||
resource database.WorkspaceResource
|
||||
build database.WorkspaceBuild
|
||||
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,
|
||||
})
|
||||
return err
|
||||
})
|
||||
eg.Go(func() (err error) {
|
||||
resource, err = a.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting resource by id: %w", err)
|
||||
}
|
||||
build, err = a.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting workspace build by job id: %w", err)
|
||||
}
|
||||
workspace, err = a.Database.GetWorkspaceByID(ctx, build.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)
|
||||
}
|
||||
|
||||
appHost := httpapi.ApplicationURL{
|
||||
AppSlugOrPort: "{{port}}",
|
||||
AgentName: workspaceAgent.Name,
|
||||
WorkspaceName: workspace.Name,
|
||||
Username: owner.Username,
|
||||
}
|
||||
vscodeProxyURI := a.AccessURL.Scheme + "://" + strings.ReplaceAll(a.AppHostname, "*", appHost.String())
|
||||
if a.AppHostname == "" {
|
||||
vscodeProxyURI += a.AccessURL.Hostname()
|
||||
}
|
||||
if a.AccessURL.Port() != "" {
|
||||
vscodeProxyURI += fmt.Sprintf(":%s", a.AccessURL.Port())
|
||||
}
|
||||
|
||||
var gitAuthConfigs uint32
|
||||
for _, cfg := range a.ExternalAuthConfigs {
|
||||
if codersdk.EnhancedExternalAuthProvider(cfg.Type).Git() {
|
||||
gitAuthConfigs++
|
||||
}
|
||||
}
|
||||
|
||||
apps, err := agentproto.DBAppsToProto(dbApps, workspaceAgent, owner.Username, workspace)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("converting workspace apps: %w", err)
|
||||
}
|
||||
|
||||
return &agentproto.Manifest{
|
||||
AgentId: workspaceAgent.ID[:],
|
||||
OwnerUsername: owner.Username,
|
||||
WorkspaceId: workspace.ID[:],
|
||||
GitAuthConfigs: gitAuthConfigs,
|
||||
EnvironmentVariables: apiAgent.EnvironmentVariables,
|
||||
Directory: apiAgent.Directory,
|
||||
VsCodePortProxyUri: vscodeProxyURI,
|
||||
MotdPath: workspaceAgent.MOTDFile,
|
||||
DisableDirectConnections: a.DisableDirectConnections,
|
||||
DerpForceWebsockets: a.DerpForceWebSockets,
|
||||
|
||||
DerpMap: tailnet.DERPMapToProto(a.DerpMapFn()),
|
||||
Scripts: agentproto.DBAgentScriptsToProto(scripts),
|
||||
Apps: apps,
|
||||
Metadata: agentproto.DBAgentMetadataToProtoDescription(metadata),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
)
|
||||
|
||||
type MetadataAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Database database.Store
|
||||
Pubsub pubsub.Pubsub
|
||||
Log slog.Logger
|
||||
}
|
||||
|
||||
func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.BatchUpdateMetadataRequest) (*agentproto.BatchUpdateMetadataResponse, error) {
|
||||
const (
|
||||
// maxValueLen is set to 2048 to stay under the 8000 byte Postgres
|
||||
// NOTIFY limit. Since both value and error can be set, the real payload
|
||||
// limit is 2 * 2048 * 4/3 <base64 expansion> = 5461 bytes + a few
|
||||
// hundred bytes for JSON syntax, key names, and metadata.
|
||||
maxValueLen = 2048
|
||||
maxErrorLen = maxValueLen
|
||||
)
|
||||
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
collectedAt := time.Now()
|
||||
dbUpdate := database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: workspaceAgent.ID,
|
||||
Key: make([]string, 0, len(req.Metadata)),
|
||||
Value: make([]string, 0, len(req.Metadata)),
|
||||
Error: make([]string, 0, len(req.Metadata)),
|
||||
CollectedAt: make([]time.Time, 0, len(req.Metadata)),
|
||||
}
|
||||
|
||||
for _, md := range req.Metadata {
|
||||
metadataError := md.Result.Error
|
||||
|
||||
// We overwrite the error if the provided payload is too long.
|
||||
if len(md.Result.Value) > maxValueLen {
|
||||
metadataError = fmt.Sprintf("value of %d bytes exceeded %d bytes", len(md.Result.Value), maxValueLen)
|
||||
md.Result.Value = md.Result.Value[:maxValueLen]
|
||||
}
|
||||
|
||||
if len(md.Result.Error) > maxErrorLen {
|
||||
metadataError = fmt.Sprintf("error of %d bytes exceeded %d bytes", len(md.Result.Error), maxErrorLen)
|
||||
md.Result.Error = ""
|
||||
}
|
||||
|
||||
// We don't want a misconfigured agent to fill the database.
|
||||
dbUpdate.Key = append(dbUpdate.Key, md.Key)
|
||||
dbUpdate.Value = append(dbUpdate.Value, md.Result.Value)
|
||||
dbUpdate.Error = append(dbUpdate.Error, metadataError)
|
||||
// We ignore the CollectedAt from the agent to avoid bugs caused by
|
||||
// clock skew.
|
||||
dbUpdate.CollectedAt = append(dbUpdate.CollectedAt, collectedAt)
|
||||
|
||||
a.Log.Debug(
|
||||
ctx, "accepted metadata report",
|
||||
slog.F("collected_at", collectedAt),
|
||||
slog.F("original_collected_at", collectedAt),
|
||||
slog.F("key", md.Key),
|
||||
slog.F("value", ellipse(md.Result.Value, 16)),
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(WorkspaceAgentMetadataChannelPayload{
|
||||
CollectedAt: collectedAt,
|
||||
Keys: dbUpdate.Key,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("marshal workspace agent metadata channel payload: %w", err)
|
||||
}
|
||||
|
||||
err = a.Database.UpdateWorkspaceAgentMetadata(ctx, dbUpdate)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace agent metadata in database: %w", err)
|
||||
}
|
||||
|
||||
err = a.Pubsub.Publish(WatchWorkspaceAgentMetadataChannel(workspaceAgent.ID), payload)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("publish workspace agent metadata: %w", err)
|
||||
}
|
||||
|
||||
return &agentproto.BatchUpdateMetadataResponse{}, nil
|
||||
}
|
||||
|
||||
func ellipse(v string, n int) string {
|
||||
if len(v) > n {
|
||||
return v[:n] + "..."
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type WorkspaceAgentMetadataChannelPayload struct {
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
func WatchWorkspaceAgentMetadataChannel(id uuid.UUID) string {
|
||||
return "workspace_agent_metadata:" + id.String()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type ServiceBannerAPI struct {
|
||||
Database database.Store
|
||||
}
|
||||
|
||||
func (a *ServiceBannerAPI) GetServiceBanner(ctx context.Context, _ *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
|
||||
serviceBannerJSON, err := a.Database.GetServiceBanner(ctx)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
return nil, xerrors.Errorf("get service banner: %w", err)
|
||||
}
|
||||
|
||||
var cfg codersdk.ServiceBannerConfig
|
||||
if serviceBannerJSON != "" {
|
||||
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshal json: %w, raw: %s", err, serviceBannerJSON)
|
||||
}
|
||||
}
|
||||
|
||||
return &agentproto.ServiceBanner{
|
||||
Enabled: cfg.Enabled,
|
||||
Message: cfg.Message,
|
||||
BackgroundColor: cfg.BackgroundColor,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"cdr.dev/slog"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/autobuild"
|
||||
"github.com/coder/coder/v2/coderd/batchstats"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
||||
"github.com/coder/coder/v2/coderd/schedule"
|
||||
)
|
||||
|
||||
type StatsAPI struct {
|
||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||
Database database.Store
|
||||
Log slog.Logger
|
||||
StatsBatcher *batchstats.Batcher
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
AgentStatsRefreshInterval time.Duration
|
||||
UpdateAgentMetricsFn func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric)
|
||||
}
|
||||
|
||||
func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
|
||||
workspaceAgent, err := a.AgentFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := a.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace by agent ID %q: %w", workspaceAgent.ID, err)
|
||||
}
|
||||
workspace := row.Workspace
|
||||
|
||||
res := &agentproto.UpdateStatsResponse{
|
||||
ReportInterval: durationpb.New(a.AgentStatsRefreshInterval),
|
||||
}
|
||||
|
||||
// An empty stat means it's just looking for the report interval.
|
||||
if len(req.Stats.ConnectionsByProto) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
a.Log.Debug(ctx, "read stats report",
|
||||
slog.F("interval", a.AgentStatsRefreshInterval),
|
||||
slog.F("workspace_id", workspace.ID),
|
||||
slog.F("payload", req),
|
||||
)
|
||||
|
||||
if req.Stats.ConnectionCount > 0 {
|
||||
var nextAutostart time.Time
|
||||
if workspace.AutostartSchedule.String != "" {
|
||||
templateSchedule, err := (*(a.TemplateScheduleStore.Load())).Get(ctx, a.Database, workspace.TemplateID)
|
||||
// If the template schedule fails to load, just default to bumping without the next trasition and log it.
|
||||
if err != nil {
|
||||
a.Log.Warn(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min",
|
||||
slog.F("workspace_id", workspace.ID),
|
||||
slog.F("template_id", workspace.TemplateID),
|
||||
slog.Error(err),
|
||||
)
|
||||
} else {
|
||||
next, allowed := autobuild.NextAutostartSchedule(time.Now(), workspace.AutostartSchedule.String, templateSchedule)
|
||||
if allowed {
|
||||
nextAutostart = next
|
||||
}
|
||||
}
|
||||
}
|
||||
ActivityBumpWorkspace(ctx, a.Log.Named("activity_bump"), a.Database, workspace.ID, nextAutostart)
|
||||
}
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
var errGroup errgroup.Group
|
||||
errGroup.Go(func() error {
|
||||
if err := a.StatsBatcher.Add(time.Now(), workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, req.Stats); err != nil {
|
||||
a.Log.Error(ctx, "failed to add stats to batcher", slog.Error(err))
|
||||
return xerrors.Errorf("can't insert workspace agent stat: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
errGroup.Go(func() error {
|
||||
err := a.Database.UpdateWorkspaceLastUsedAt(ctx, database.UpdateWorkspaceLastUsedAtParams{
|
||||
ID: workspace.ID,
|
||||
LastUsedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("can't update workspace LastUsedAt: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if a.UpdateAgentMetricsFn != nil {
|
||||
errGroup.Go(func() error {
|
||||
user, err := a.Database.GetUserByID(ctx, workspace.OwnerID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("can't get user: %w", err)
|
||||
}
|
||||
|
||||
a.UpdateAgentMetricsFn(ctx, prometheusmetrics.AgentMetricLabels{
|
||||
Username: user.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: workspaceAgent.Name,
|
||||
TemplateName: row.TemplateName,
|
||||
}, req.Stats.Metrics)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err = errGroup.Wait()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update stats in database: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
)
|
||||
|
||||
type TailnetAPI struct {
|
||||
Ctx context.Context
|
||||
DerpMapFn func() *tailcfg.DERPMap
|
||||
DerpMapUpdateFrequency time.Duration
|
||||
}
|
||||
|
||||
func (a *TailnetAPI) StreamDERPMaps(_ *tailnetproto.StreamDERPMapsRequest, stream agentproto.DRPCAgent_StreamDERPMapsStream) error {
|
||||
defer stream.Close()
|
||||
|
||||
ticker := time.NewTicker(a.DerpMapUpdateFrequency)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastDERPMap *tailcfg.DERPMap
|
||||
for {
|
||||
derpMap := a.DerpMapFn()
|
||||
if lastDERPMap == nil || !tailnet.CompareDERPMaps(lastDERPMap, derpMap) {
|
||||
protoDERPMap := tailnet.DERPMapToProto(derpMap)
|
||||
err := stream.Send(protoDERPMap)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("send derp map: %w", err)
|
||||
}
|
||||
lastDERPMap = derpMap
|
||||
}
|
||||
|
||||
ticker.Reset(a.DerpMapUpdateFrequency)
|
||||
select {
|
||||
case <-stream.Context().Done():
|
||||
return nil
|
||||
case <-a.Ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (*TailnetAPI) CoordinateTailnet(_ agentproto.DRPCAgent_CoordinateTailnetStream) error {
|
||||
// TODO: implement this
|
||||
return xerrors.New("CoordinateTailnet is unimplemented")
|
||||
}
|
||||
Reference in New Issue
Block a user