feat: add support for coder_script (#9584)

* Add basic migrations

* Improve schema

* Refactor agent scripts into it's own package

* Support legacy start and stop script format

* Pipe the scripts!

* Finish the piping

* Fix context usage

* It works!

* Fix sql query

* Fix SQL query

* Rename `LogSourceID` -> `SourceID`

* Fix the FE

* fmt

* Rename migrations

* Fix log tests

* Fix lint err

* Fix gen

* Fix story type

* Rename source to script

* Fix schema jank

* Uncomment test

* Rename proto to TimeoutSeconds

* Fix comments

* Fix comments

* Fix legacy endpoint without specified log_source

* Fix non-blocking by default in agent

* Fix resources tests

* Fix dbfake

* Fix resources

* Fix linting I think

* Add fixtures

* fmt

* Fix startup script behavior

* Fix comments

* Fix context

* Fix cancel

* Fix SQL tests

* Fix e2e tests

* Interrupt on Windows

* Fix agent leaking script process

* Fix migrations

* Fix stories

* Fix duplicate logs appearing

* Gen

* Fix log location

* Fix tests

* Fix tests

* Fix log output

* Show display name in output

* Fix print

* Return timeout on start context

* Gen

* Fix fixture

* Fix the agent status

* Fix startup timeout msg

* Fix command using shared context

* Fix timeout draining

* Change signal type

* Add deterministic colors to startup script logs

---------

Co-authored-by: Muhammad Atif Ali <atif@coder.com>
This commit is contained in:
Kyle Carberry
2023-09-25 16:47:17 -05:00
committed by GitHub
parent dac1375880
commit 1262eef2c0
61 changed files with 3820 additions and 2117 deletions

View File

@ -23,6 +23,13 @@ import (
"github.com/coder/retry"
)
// ExternalLogSourceID is the statically-defined ID of a log-source that
// appears as "External" in the dashboard.
//
// This is to support legacy API-consumers that do not create their own
// log-source. This should be removed in the future.
var ExternalLogSourceID = uuid.MustParse("3b579bf4-1ed8-4b99-87a8-e9a1e3410410")
// New returns a client that is used to interact with the
// Coder API from a workspace agent.
func New(serverURL *url.URL) *Client {
@ -91,14 +98,21 @@ type Manifest struct {
DERPMap *tailcfg.DERPMap `json:"derpmap"`
DERPForceWebSockets bool `json:"derp_force_websockets"`
EnvironmentVariables map[string]string `json:"environment_variables"`
StartupScript string `json:"startup_script"`
StartupScriptTimeout time.Duration `json:"startup_script_timeout"`
Directory string `json:"directory"`
MOTDFile string `json:"motd_file"`
ShutdownScript string `json:"shutdown_script"`
ShutdownScriptTimeout time.Duration `json:"shutdown_script_timeout"`
DisableDirectConnections bool `json:"disable_direct_connections"`
Metadata []codersdk.WorkspaceAgentMetadataDescription `json:"metadata"`
Scripts []codersdk.WorkspaceAgentScript `json:"scripts"`
}
type LogSource struct {
ID uuid.UUID `json:"id"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
}
type Script struct {
Script string `json:"script"`
}
// Manifest fetches manifest for the currently authenticated workspace agent.
@ -631,14 +645,14 @@ func (c *Client) PostStartup(ctx context.Context, req PostStartupRequest) error
}
type Log struct {
CreatedAt time.Time `json:"created_at"`
Output string `json:"output"`
Level codersdk.LogLevel `json:"level"`
Source codersdk.WorkspaceAgentLogSource `json:"source"`
CreatedAt time.Time `json:"created_at"`
Output string `json:"output"`
Level codersdk.LogLevel `json:"level"`
}
type PatchLogs struct {
Logs []Log `json:"logs"`
LogSourceID uuid.UUID `json:"log_source_id"`
Logs []Log `json:"logs"`
}
// PatchLogs writes log messages to the agent startup script.
@ -655,6 +669,29 @@ func (c *Client) PatchLogs(ctx context.Context, req PatchLogs) error {
return nil
}
type PostLogSource struct {
// ID is a unique identifier for the log source.
// It is scoped to a workspace agent, and can be statically
// defined inside code to prevent duplicate sources from being
// created for the same agent.
ID uuid.UUID `json:"id"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
}
func (c *Client) PostLogSource(ctx context.Context, req PostLogSource) (codersdk.WorkspaceAgentLogSource, error) {
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/log-source", req)
if err != nil {
return codersdk.WorkspaceAgentLogSource{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return codersdk.WorkspaceAgentLogSource{}, codersdk.ReadBodyAsError(res)
}
var logSource codersdk.WorkspaceAgentLogSource
return logSource, json.NewDecoder(res.Body).Decode(&logSource)
}
// GetServiceBanner relays the service banner config.
func (c *Client) GetServiceBanner(ctx context.Context) (codersdk.ServiceBannerConfig, error) {
res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/appearance", nil)

View File

@ -10,6 +10,8 @@ import (
"golang.org/x/xerrors"
"github.com/google/uuid"
"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/retry"
@ -20,7 +22,7 @@ type startupLogsWriter struct {
ctx context.Context
send func(ctx context.Context, log ...Log) error
level codersdk.LogLevel
source codersdk.WorkspaceAgentLogSource
source uuid.UUID
}
func (w *startupLogsWriter) Write(p []byte) (int, error) {
@ -44,7 +46,6 @@ func (w *startupLogsWriter) Write(p []byte) (int, error) {
CreatedAt: time.Now().UTC(), // UTC, like dbtime.Now().
Level: w.level,
Output: string(partial) + string(p[:nl-cr]),
Source: w.source,
})
if err != nil {
return n - len(p), err
@ -67,24 +68,20 @@ func (w *startupLogsWriter) Close() error {
CreatedAt: time.Now().UTC(), // UTC, like dbtime.Now().
Level: w.level,
Output: w.buf.String(),
Source: w.source,
})
}
return nil
}
// StartupLogsWriter returns an io.WriteCloser that sends logs via the
// LogsWriter returns an io.WriteCloser that sends logs via the
// provided sender. The sender is expected to be non-blocking. Calling
// Close flushes any remaining partially written log lines but is
// otherwise no-op. If the context passed to StartupLogsWriter is
// otherwise no-op. If the context passed to LogsWriter is
// canceled, any remaining logs will be discarded.
//
// Neither Write nor Close is safe for concurrent use and must be used
// by a single goroutine.
func StartupLogsWriter(ctx context.Context, sender func(ctx context.Context, log ...Log) error, source codersdk.WorkspaceAgentLogSource, level codersdk.LogLevel) io.WriteCloser {
if source == "" {
source = codersdk.WorkspaceAgentLogSourceExternal
}
func LogsWriter(ctx context.Context, sender func(ctx context.Context, log ...Log) error, source uuid.UUID, level codersdk.LogLevel) io.WriteCloser {
return &startupLogsWriter{
ctx: ctx,
send: sender,
@ -98,7 +95,7 @@ func StartupLogsWriter(ctx context.Context, sender func(ctx context.Context, log
// has been called. Calling sendLog concurrently is not supported. If
// the context passed to flushAndClose is canceled, any remaining logs
// will be discarded.
func LogsSender(patchLogs func(ctx context.Context, req PatchLogs) error, logger slog.Logger) (sendLog func(ctx context.Context, log ...Log) error, flushAndClose func(context.Context) error) {
func LogsSender(sourceID uuid.UUID, patchLogs func(ctx context.Context, req PatchLogs) error, logger slog.Logger) (sendLog func(ctx context.Context, log ...Log) error, flushAndClose func(context.Context) error) {
// The main context is used to close the sender goroutine and cancel
// any outbound requests to the API. The shutdown context is used to
// signal the sender goroutine to flush logs and then exit.
@ -158,7 +155,8 @@ func LogsSender(patchLogs func(ctx context.Context, req PatchLogs) error, logger
// shutdown.
for r := retry.New(time.Second, 5*time.Second); r.Wait(ctx); {
err := patchLogs(ctx, PatchLogs{
Logs: backlog,
Logs: backlog,
LogSourceID: sourceID,
})
if err == nil {
break

View File

@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
@ -39,12 +40,10 @@ func TestStartupLogsWriter_Write(t *testing.T) {
ctx: context.Background(),
level: codersdk.LogLevelInfo,
writes: []string{"hello world\n"},
source: codersdk.WorkspaceAgentLogSourceShutdownScript,
want: []agentsdk.Log{
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceShutdownScript,
},
},
},
@ -57,12 +56,10 @@ func TestStartupLogsWriter_Write(t *testing.T) {
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "goodbye world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
},
},
@ -75,32 +72,26 @@ func TestStartupLogsWriter_Write(t *testing.T) {
{
Level: codersdk.LogLevelInfo,
Output: "",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "goodbye world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
},
},
@ -113,7 +104,6 @@ func TestStartupLogsWriter_Write(t *testing.T) {
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
},
},
@ -127,12 +117,10 @@ func TestStartupLogsWriter_Write(t *testing.T) {
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "goodbye world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
},
},
@ -145,12 +133,10 @@ func TestStartupLogsWriter_Write(t *testing.T) {
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "goodbye world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
},
},
@ -163,17 +149,14 @@ func TestStartupLogsWriter_Write(t *testing.T) {
{
Level: codersdk.LogLevelInfo,
Output: "hello world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "\r",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
{
Level: codersdk.LogLevelInfo,
Output: "goodbye world",
Source: codersdk.WorkspaceAgentLogSourceExternal,
},
},
},
@ -201,7 +184,7 @@ func TestStartupLogsWriter_Write(t *testing.T) {
got = append(got, log...)
return nil
}
w := agentsdk.StartupLogsWriter(tt.ctx, send, tt.source, tt.level)
w := agentsdk.LogsWriter(tt.ctx, send, uuid.New(), tt.level)
for _, s := range tt.writes {
_, err := w.Write([]byte(s))
if err != nil {
@ -291,7 +274,7 @@ func TestStartupLogsSender(t *testing.T) {
return nil
}
sendLog, flushAndClose := agentsdk.LogsSender(patchLogs, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
sendLog, flushAndClose := agentsdk.LogsSender(uuid.New(), patchLogs, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
defer func() {
err := flushAndClose(ctx)
require.NoError(t, err)
@ -330,7 +313,7 @@ func TestStartupLogsSender(t *testing.T) {
return nil
}
sendLog, flushAndClose := agentsdk.LogsSender(patchLogs, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
sendLog, flushAndClose := agentsdk.LogsSender(uuid.New(), patchLogs, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
defer func() {
_ = flushAndClose(ctx)
}()
@ -361,7 +344,7 @@ func TestStartupLogsSender(t *testing.T) {
return nil
}
sendLog, flushAndClose := agentsdk.LogsSender(patchLogs, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
sendLog, flushAndClose := agentsdk.LogsSender(uuid.New(), patchLogs, slogtest.Make(t, nil).Leveled(slog.LevelDebug))
defer func() {
_ = flushAndClose(ctx)
}()