Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions scaletest/taskstatus/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package taskstatus

import (
"context"
"net/http"
"net/url"

"github.com/google/uuid"
"golang.org/x/xerrors"

"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
)

// createExternalWorkspaceResult contains the results from creating an external workspace.
type createExternalWorkspaceResult struct {
WorkspaceID uuid.UUID
AgentToken string
}

// client abstracts the details of using codersdk.Client for workspace operations.
// This interface allows for easier testing by enabling mock implementations and
// provides a cleaner separation of concerns.
//
// The interface is designed to be initialized in two phases:
// 1. Create the client with newClient(coderClient)
// 2. Configure logging when the io.Writer is available in Run()
type client interface {
// createExternalWorkspace creates an external workspace and returns the workspace ID
// and agent token for the first external agent found in the workspace resources.
createExternalWorkspace(ctx context.Context, req codersdk.CreateWorkspaceRequest) (createExternalWorkspaceResult, error)

// watchWorkspace watches for updates to a workspace.
watchWorkspace(ctx context.Context, workspaceID uuid.UUID) (<-chan codersdk.Workspace, error)

// initialize sets up the client with the provided logger, which is only available after Run() is called.
initialize(logger slog.Logger)
}

// appStatusPatcher abstracts the details of using agentsdk.Client for updating app status.
// This interface is separate from client because it requires an agent token which is only
// available after creating an external workspace.
type appStatusPatcher interface {
// patchAppStatus updates the status of a workspace app.
patchAppStatus(ctx context.Context, req agentsdk.PatchAppStatus) error

// initialize sets up the patcher with the provided logger and agent token.
initialize(logger slog.Logger, agentToken string)
}

// sdkClient is the concrete implementation of the client interface using
// codersdk.Client.
type sdkClient struct {
coderClient *codersdk.Client
}

// newClient creates a new client implementation using the provided codersdk.Client.
func newClient(coderClient *codersdk.Client) client {
return &sdkClient{
coderClient: coderClient,
}
}

func (c *sdkClient) createExternalWorkspace(ctx context.Context, req codersdk.CreateWorkspaceRequest) (createExternalWorkspaceResult, error) {
// Create the workspace
workspace, err := c.coderClient.CreateUserWorkspace(ctx, codersdk.Me, req)
if err != nil {
return createExternalWorkspaceResult{}, err
}

// Get the workspace with latest build details
workspace, err = c.coderClient.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspace.Name, codersdk.WorkspaceOptions{})
if err != nil {
return createExternalWorkspaceResult{}, err
}

// Find external agents in resources
for _, resource := range workspace.LatestBuild.Resources {
if resource.Type != "coder_external_agent" || len(resource.Agents) == 0 {
continue
}

// Get credentials for the first agent
agent := resource.Agents[0]
credentials, err := c.coderClient.WorkspaceExternalAgentCredentials(ctx, workspace.ID, agent.Name)
if err != nil {
return createExternalWorkspaceResult{}, err
}

return createExternalWorkspaceResult{
WorkspaceID: workspace.ID,
AgentToken: credentials.AgentToken,
}, nil
}

return createExternalWorkspaceResult{}, xerrors.Errorf("no external agent found in workspace")
}

func (c *sdkClient) watchWorkspace(ctx context.Context, workspaceID uuid.UUID) (<-chan codersdk.Workspace, error) {
return c.coderClient.WatchWorkspace(ctx, workspaceID)
}

func (c *sdkClient) initialize(logger slog.Logger) {
// Configure the coder client logging
c.coderClient.SetLogger(logger)
c.coderClient.SetLogBodies(true)
}

// sdkAppStatusPatcher is the concrete implementation of the appStatusPatcher interface
// using agentsdk.Client.
type sdkAppStatusPatcher struct {
agentClient *agentsdk.Client
url *url.URL
httpClient *http.Client
}

// newAppStatusPatcher creates a new appStatusPatcher implementation.
func newAppStatusPatcher(client *codersdk.Client) appStatusPatcher {
return &sdkAppStatusPatcher{
url: client.URL,
httpClient: client.HTTPClient,
}
}

func (p *sdkAppStatusPatcher) patchAppStatus(ctx context.Context, req agentsdk.PatchAppStatus) error {
if p.agentClient == nil {
panic("agentClient not initialized - call initialize first")
}
return p.agentClient.PatchAppStatus(ctx, req)
}

func (p *sdkAppStatusPatcher) initialize(logger slog.Logger, agentToken string) {
// Create and configure the agent client with the provided token
p.agentClient = agentsdk.New(
p.url,
agentsdk.WithFixedToken(agentToken),
codersdk.WithHTTPClient(p.httpClient),
codersdk.WithLogger(logger),
codersdk.WithLogBodies(),
)
}

// Ensure sdkClient implements the client interface.
var _ client = (*sdkClient)(nil)

// Ensure sdkAppStatusPatcher implements the appStatusPatcher interface.
var _ appStatusPatcher = (*sdkAppStatusPatcher)(nil)
73 changes: 73 additions & 0 deletions scaletest/taskstatus/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package taskstatus

import (
"sync"
"time"

"github.com/google/uuid"
"golang.org/x/xerrors"
)

type Config struct {
// TemplateID is the template ID to use for creating the external workspace.
TemplateID uuid.UUID `json:"template_id"`

// WorkspaceName is the name for the external workspace to create.
WorkspaceName string `json:"workspace_name"`

// AppSlug is the slug of the app designated as the AI Agent.
AppSlug string `json:"app_slug"`

// When the runner has connected to the watch-ws endpoint, it will call Done once on this wait group. Used to
// coordinate multiple runners from the higher layer.
ConnectedWaitGroup *sync.WaitGroup `json:"-"`

// We read on this channel before starting to report task statuses. Used to coordinate multiple runners from the
// higher layer.
StartReporting chan struct{} `json:"-"`

// Time between reporting task statuses.
ReportStatusPeriod time.Duration `json:"report_status_period"`

// Total time to report task statuses, starting from when we successfully read from the StartReporting channel.
ReportStatusDuration time.Duration `json:"report_status_duration"`

Metrics *Metrics `json:"-"`
MetricLabelValues []string `json:"metric_label_values"`
}

func (c *Config) Validate() error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks unused

if c.TemplateID == uuid.Nil {
return xerrors.Errorf("validate template_id: must not be nil")
}

if c.WorkspaceName == "" {
return xerrors.Errorf("validate workspace_name: must not be empty")
}

if c.AppSlug == "" {
return xerrors.Errorf("validate app_slug: must not be empty")
}

if c.ConnectedWaitGroup == nil {
return xerrors.Errorf("validate connected_wait_group: must not be nil")
}

if c.StartReporting == nil {
return xerrors.Errorf("validate start_reporting: must not be nil")
}

if c.ReportStatusPeriod <= 0 {
return xerrors.Errorf("validate report_status_period: must be greater than zero")
}

if c.ReportStatusDuration <= 0 {
return xerrors.Errorf("validate report_status_duration: must be greater than zero")
}

if c.Metrics == nil {
return xerrors.Errorf("validate metrics: must not be nil")
}

return nil
}
36 changes: 36 additions & 0 deletions scaletest/taskstatus/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package taskstatus

import "github.com/prometheus/client_golang/prometheus"

type Metrics struct {
TaskStatusToWorkspaceUpdateLatencySeconds prometheus.HistogramVec
MissingStatusUpdatesTotal prometheus.CounterVec
ReportTaskStatusErrorsTotal prometheus.CounterVec
}

func NewMetrics(reg prometheus.Registerer, labelNames ...string) *Metrics {
m := &Metrics{
TaskStatusToWorkspaceUpdateLatencySeconds: *prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "coderd",
Subsystem: "scaletest",
Name: "task_status_to_workspace_update_latency_seconds",
Help: "Time in seconds between reporting a task status and receiving the workspace update.",
}, labelNames),
MissingStatusUpdatesTotal: *prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "scaletest",
Name: "missing_status_updates_total",
Help: "Total number of missing status updates.",
}, labelNames),
ReportTaskStatusErrorsTotal: *prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "scaletest",
Name: "report_task_status_errors_total",
Help: "Total number of errors when reporting task status.",
}, labelNames),
}
reg.MustRegister(m.TaskStatusToWorkspaceUpdateLatencySeconds)
reg.MustRegister(m.MissingStatusUpdatesTotal)
reg.MustRegister(m.ReportTaskStatusErrorsTotal)
return m
}
Loading
Loading