From f0ac52e83ce32d5955f848ba3497098746ce9d90 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 15 Jun 2026 12:34:48 +0200 Subject: [PATCH] feat: persist boundary logs (#24812) Add database persistence to `ReportBoundaryLogs`. On first log for a session, the handler lazy-creates a `boundary_sessions` row, then batch-inserts all `BoundaryLog` entries into `boundary_logs`. Structured logging and usage tracking are preserved. Old boundary clients (no `session_id`) fall back to log-only mode. > [!NOTE] > This PR was authored by Coder Agents. --- agent/boundary_logs_test.go | 215 +++++---- coderd/agentapi/api.go | 2 + coderd/agentapi/boundary_logs.go | 163 ++++++- coderd/agentapi/boundary_logs_test.go | 456 ++++++++++++++++++ coderd/database/dbauthz/dbauthz.go | 7 +- coderd/database/dbauthz/dbauthz_test.go | 16 +- coderd/database/dump.sql | 3 - coderd/database/foreign_key_constraint.go | 1 - ...520_drop_boundary_logs_session_fk.down.sql | 10 + ...00520_drop_boundary_logs_session_fk.up.sql | 6 + coderd/database/modelmethods.go | 7 - coderd/database/queries.sql.go | 2 +- coderd/database/queries/boundarylogs.sql | 2 +- .../ai-tasks-disabled.tfplan.json | 4 +- .../ai-tasks-disabled.tfstate.dot | 20 + .../ai-tasks-disabled.tfstate.json | 75 +++ .../converted_state.state.golden | 9 + 17 files changed, 868 insertions(+), 130 deletions(-) create mode 100644 coderd/agentapi/boundary_logs_test.go create mode 100644 coderd/database/migrations/000520_drop_boundary_logs_session_fk.down.sql create mode 100644 coderd/database/migrations/000520_drop_boundary_logs_session_fk.up.sql create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.dot create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.json create mode 100644 provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.state.golden diff --git a/agent/boundary_logs_test.go b/agent/boundary_logs_test.go index 3d4cf15069..64afd6b47c 100644 --- a/agent/boundary_logs_test.go +++ b/agent/boundary_logs_test.go @@ -42,111 +42,134 @@ func sendBoundaryLogsRequest(t *testing.T, conn net.Conn, req *agentproto.Report require.NoError(t, err) } -// TestBoundaryLogs_EndToEnd is an end-to-end test that sends a protobuf -// message over the agent's unix socket (as boundary would) and verifies -// it is ultimately logged by coderd with the correct structured fields. func TestBoundaryLogs_EndToEnd(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock") - srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath, prometheus.NewRegistry()) - - err := srv.Start() - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, srv.Close()) }) - - sink := testutil.NewFakeSink(t) - logger := sink.Logger(slog.LevelInfo) - workspaceID := uuid.New() - templateID := uuid.New() - templateVersionID := uuid.New() - reporter := &agentapi.BoundaryLogsAPI{ - Log: logger, - WorkspaceID: workspaceID, - TemplateID: templateID, - TemplateVersionID: templateVersionID, - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - forwarderDone := make(chan error, 1) - go func() { - forwarderDone <- srv.RunForwarder(ctx, reporter) - }() - - conn, err := net.Dial("unix", socketPath) - require.NoError(t, err) - defer conn.Close() - - // Allowed HTTP request. - req := &agentproto.ReportBoundaryLogsRequest{ - Logs: []*agentproto.BoundaryLog{ - { - Allowed: true, - Time: timestamppb.Now(), - Resource: &agentproto.BoundaryLog_HttpRequest_{ - HttpRequest: &agentproto.BoundaryLog_HttpRequest{ - Method: "GET", - Url: "https://example.com/allowed", - MatchedRule: "*.example.com", - }, - }, - }, + tests := []struct { + name string + sessionID string + }{ + { + name: "NoSessionID", + sessionID: "", + }, + { + name: "WithSessionID", + sessionID: uuid.New().String(), }, } - sendBoundaryLogsRequest(t, conn, req) - require.Eventually(t, func() bool { - return len(sink.Entries()) >= 1 - }, testutil.WaitShort, testutil.IntervalFast) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - entries := sink.Entries() - require.Len(t, entries, 1) - entry := entries[0] - require.Equal(t, slog.LevelInfo, entry.Level) - require.Equal(t, "boundary_request", entry.Message) - require.Equal(t, "allow", getField(entry.Fields, "decision")) - require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id")) - require.Equal(t, templateID.String(), getField(entry.Fields, "template_id")) - require.Equal(t, templateVersionID.String(), getField(entry.Fields, "template_version_id")) - require.Equal(t, "GET", getField(entry.Fields, "http_method")) - require.Equal(t, "https://example.com/allowed", getField(entry.Fields, "http_url")) - require.Equal(t, "*.example.com", getField(entry.Fields, "matched_rule")) + socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock") + srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath, prometheus.NewRegistry()) - // Denied HTTP request. - req2 := &agentproto.ReportBoundaryLogsRequest{ - Logs: []*agentproto.BoundaryLog{ - { - Allowed: false, - Time: timestamppb.Now(), - Resource: &agentproto.BoundaryLog_HttpRequest_{ - HttpRequest: &agentproto.BoundaryLog_HttpRequest{ - Method: "POST", - Url: "https://blocked.com/denied", + err := srv.Start() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, srv.Close()) }) + + sink := testutil.NewFakeSink(t) + logger := sink.Logger(slog.LevelInfo) + workspaceID := uuid.New() + templateID := uuid.New() + templateVersionID := uuid.New() + reporter := &agentapi.BoundaryLogsAPI{ + Log: logger, + WorkspaceID: workspaceID, + TemplateID: templateID, + TemplateVersionID: templateVersionID, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + forwarderDone := make(chan error, 1) + go func() { + forwarderDone <- srv.RunForwarder(ctx, reporter) + }() + + conn, err := net.Dial("unix", socketPath) + require.NoError(t, err) + defer conn.Close() + + req := &agentproto.ReportBoundaryLogsRequest{ + SessionId: tc.sessionID, + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.Now(), + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://example.com/allowed", + MatchedRule: "*.example.com", + }, + }, + SequenceNumber: 0, }, }, - }, - }, + } + sendBoundaryLogsRequest(t, conn, req) + + require.Eventually(t, func() bool { + return len(sink.Entries()) >= 1 + }, testutil.WaitShort, testutil.IntervalFast) + + entries := sink.Entries() + require.Len(t, entries, 1) + entry := entries[0] + require.Equal(t, slog.LevelInfo, entry.Level) + require.Equal(t, "boundary_request", entry.Message) + require.Equal(t, "allow", getField(entry.Fields, "decision")) + require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id")) + require.Equal(t, templateID.String(), getField(entry.Fields, "template_id")) + require.Equal(t, templateVersionID.String(), getField(entry.Fields, "template_version_id")) + require.Equal(t, "GET", getField(entry.Fields, "http_method")) + require.Equal(t, "https://example.com/allowed", getField(entry.Fields, "http_url")) + require.Equal(t, "*.example.com", getField(entry.Fields, "matched_rule")) + require.Equal(t, tc.sessionID, getField(entry.Fields, "session_id")) + require.Equal(t, int32(0), getField(entry.Fields, "sequence_number")) + + req2 := &agentproto.ReportBoundaryLogsRequest{ + SessionId: tc.sessionID, + Logs: []*agentproto.BoundaryLog{ + { + Allowed: false, + Time: timestamppb.Now(), + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "POST", + Url: "https://blocked.com/denied", + }, + }, + SequenceNumber: 1, + }, + }, + } + sendBoundaryLogsRequest(t, conn, req2) + + require.Eventually(t, func() bool { + return len(sink.Entries()) >= 2 + }, testutil.WaitShort, testutil.IntervalFast) + + entries = sink.Entries() + entry = entries[1] + require.Len(t, entries, 2) + require.Equal(t, slog.LevelInfo, entry.Level) + require.Equal(t, "boundary_request", entry.Message) + require.Equal(t, "deny", getField(entry.Fields, "decision")) + require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id")) + require.Equal(t, templateID.String(), getField(entry.Fields, "template_id")) + require.Equal(t, templateVersionID.String(), getField(entry.Fields, "template_version_id")) + require.Equal(t, "POST", getField(entry.Fields, "http_method")) + require.Equal(t, "https://blocked.com/denied", getField(entry.Fields, "http_url")) + require.Equal(t, nil, getField(entry.Fields, "matched_rule")) + require.Equal(t, tc.sessionID, getField(entry.Fields, "session_id")) + require.Equal(t, int32(1), getField(entry.Fields, "sequence_number")) + + cancel() + <-forwarderDone + }) } - sendBoundaryLogsRequest(t, conn, req2) - - require.Eventually(t, func() bool { - return len(sink.Entries()) >= 2 - }, testutil.WaitShort, testutil.IntervalFast) - - entries = sink.Entries() - entry = entries[1] - require.Len(t, entries, 2) - require.Equal(t, slog.LevelInfo, entry.Level) - require.Equal(t, "boundary_request", entry.Message) - require.Equal(t, "deny", getField(entry.Fields, "decision")) - require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id")) - require.Equal(t, templateID.String(), getField(entry.Fields, "template_id")) - require.Equal(t, templateVersionID.String(), getField(entry.Fields, "template_version_id")) - require.Equal(t, "POST", getField(entry.Fields, "http_method")) - require.Equal(t, "https://blocked.com/denied", getField(entry.Fields, "http_url")) - require.Equal(t, nil, getField(entry.Fields, "matched_rule")) - - cancel() - <-forwarderDone } diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index 32d65adee2..3c2eeab1ff 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -237,6 +237,8 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge api.BoundaryLogsAPI = &BoundaryLogsAPI{ Log: opts.Log, + Database: opts.Database, + AgentID: opts.AgentID, WorkspaceID: opts.WorkspaceID, OwnerID: opts.OwnerID, TemplateID: workspace.TemplateID, diff --git a/coderd/agentapi/boundary_logs.go b/coderd/agentapi/boundary_logs.go index 207d5590ac..41ad5daf1f 100644 --- a/coderd/agentapi/boundary_logs.go +++ b/coderd/agentapi/boundary_logs.go @@ -2,17 +2,46 @@ package agentapi import ( "context" + "database/sql" + "errors" + "fmt" "time" "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/boundaryusage" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" ) +const maxBoundaryLogsPerBatch = 1000 + +// ErrBatchSizeExceeded matches any BatchSizeExceededError via errors.Is. +var ErrBatchSizeExceeded = xerrors.New("boundary logs batch size exceeded") + +// BatchSizeExceededError is returned when a ReportBoundaryLogs request +// exceeds maxBoundaryLogsPerBatch. Match it with errors.As for the sizes, +// or errors.Is(err, ErrBatchSizeExceeded) for the category. +type BatchSizeExceededError struct { + BatchSize int + MaxSize int +} + +func (e BatchSizeExceededError) Error() string { + return fmt.Sprintf("batch size %d exceeds maximum of %d", e.BatchSize, e.MaxSize) +} + +func (BatchSizeExceededError) Is(target error) bool { + return target == ErrBatchSizeExceeded +} + type BoundaryLogsAPI struct { Log slog.Logger + Database database.Store + AgentID uuid.UUID WorkspaceID uuid.UUID OwnerID uuid.UUID TemplateID uuid.UUID @@ -23,8 +52,62 @@ type BoundaryLogsAPI struct { func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error) { var allowed, denied int64 + if len(req.Logs) == 0 { + a.Log.Debug(ctx, "empty boundary logs request, skipping") + return &agentproto.ReportBoundaryLogsResponse{}, nil + } + + if len(req.Logs) > maxBoundaryLogsPerBatch { + return nil, BatchSizeExceededError{BatchSize: len(req.Logs), MaxSize: maxBoundaryLogsPerBatch} + } + + now := dbtime.Now() + + // Parse session_id if present. Old boundary clients may not send it, + // so a missing or invalid session_id disables DB persistence but + // structured logging and usage tracking still run. + var sessionID uuid.UUID + persistEnabled := false + if raw := req.GetSessionId(); raw != "" { + parsed, parseErr := uuid.Parse(raw) + if parseErr != nil { + a.Log.Warn(ctx, "invalid session_id, persistence disabled for this batch", + slog.F("raw_session_id", raw), + slog.Error(parseErr)) + } else { + sessionID = parsed + persistEnabled = true + } + } + + if persistEnabled { + // Lazy-create the boundary session on first log arrival. + // If this fails (transient DB error), we continue so that + // logs are still persisted. The session will be created on + // a subsequent batch since every request carries the session + // details. + if sessionErr := a.ensureSession(ctx, sessionID, req.GetConfinedProcessName(), now); sessionErr != nil { + a.Log.Error(ctx, "failed to ensure boundary session", + slog.F("session_id", sessionID.String()), + slog.Error(sessionErr)) + } + } + + // Collect batch insert params while iterating. + batch := database.InsertBoundaryLogsParams{ + SessionID: sessionID, + ID: nil, + SequenceNumber: nil, + CapturedAt: nil, + CreatedAt: nil, + Proto: nil, + Method: nil, + Detail: nil, + MatchedRule: nil, + } + for _, l := range req.Logs { - var logTime time.Time + logTime := now if l.Time != nil { logTime = l.Time.AsTime() } @@ -45,6 +128,8 @@ func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentprot fields := []slog.Field{ slog.F("decision", allowBoolToString(l.Allowed)), + slog.F("session_id", req.SessionId), + slog.F("sequence_number", l.SequenceNumber), slog.F("workspace_id", a.WorkspaceID.String()), slog.F("template_id", a.TemplateID.String()), slog.F("template_version_id", a.TemplateVersionID.String()), @@ -57,12 +142,35 @@ func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentprot } a.Log.With(fields...).Info(ctx, "boundary_request") + + var matchedRule string + if l.Allowed && r.HttpRequest.MatchedRule != "" { + matchedRule = r.HttpRequest.MatchedRule + } + batch.ID = append(batch.ID, uuid.New()) + batch.SequenceNumber = append(batch.SequenceNumber, l.SequenceNumber) + batch.CapturedAt = append(batch.CapturedAt, now) + batch.CreatedAt = append(batch.CreatedAt, logTime) + batch.Proto = append(batch.Proto, "http") + batch.Method = append(batch.Method, r.HttpRequest.Method) + batch.Detail = append(batch.Detail, r.HttpRequest.Url) + batch.MatchedRule = append(batch.MatchedRule, matchedRule) default: a.Log.Warn(ctx, "unknown resource type", slog.F("workspace_id", a.WorkspaceID.String())) } } + // Batch-insert all collected logs in a single query. + if persistEnabled && len(batch.ID) > 0 { + if insertErr := a.insertLogs(ctx, batch); insertErr != nil { + a.Log.Error(ctx, "failed to insert boundary logs", + slog.F("session_id", sessionID.String()), + slog.F("count", len(batch.ID)), + slog.Error(insertErr)) + } + } + if a.BoundaryUsageTracker != nil && (allowed > 0 || denied > 0) { a.BoundaryUsageTracker.Track(a.WorkspaceID, a.OwnerID, allowed, denied) } @@ -70,6 +178,59 @@ func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentprot return &agentproto.ReportBoundaryLogsResponse{}, nil } +// ensureSession creates the boundary_sessions row if it does not +// already exist. +func (a *BoundaryLogsAPI) ensureSession(ctx context.Context, sessionID uuid.UUID, confinedProcess string, now time.Time) error { + if a.Database == nil { + return nil + } + + // Check the database in case another replica or reconnection + // already created this session. + _, err := a.Database.GetBoundarySessionByID(ctx, sessionID) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("check boundary session existence: %w", err) + } + + // Session does not exist; create it. started_at is the time + // the first log is received by coderd, per the RFC. + _, err = a.Database.InsertBoundarySession(ctx, database.InsertBoundarySessionParams{ + ID: sessionID, + WorkspaceAgentID: a.AgentID, + OwnerID: uuid.NullUUID{UUID: a.OwnerID, Valid: true}, + ConfinedProcessName: confinedProcess, + StartedAt: now, + UpdatedAt: now, + }) + if err != nil { + // A second coderd replica may receive a batch for this session + // before the first replica has finished inserting it. Both + // attempt the INSERT; the second fails with a primary-key + // unique violation. Treat it as success because the session + // now exists. + if database.IsUniqueViolation(err, database.UniqueBoundarySessionsPkey) { + a.Log.Debug(ctx, "boundary session already created by another replica", + slog.F("session_id", sessionID.String())) + return nil + } + return xerrors.Errorf("insert boundary session: %w", err) + } + + return nil +} + +// insertLogs persists a batch of boundary log entries. +func (a *BoundaryLogsAPI) insertLogs(ctx context.Context, batch database.InsertBoundaryLogsParams) error { + if a.Database == nil { + return nil + } + _, err := a.Database.InsertBoundaryLogs(ctx, batch) + return err +} + //nolint:revive // This stringifies the boolean argument. func allowBoolToString(b bool) string { if b { diff --git a/coderd/agentapi/boundary_logs_test.go b/coderd/agentapi/boundary_logs_test.go new file mode 100644 index 0000000000..ad8baaec8e --- /dev/null +++ b/coderd/agentapi/boundary_logs_test.go @@ -0,0 +1,456 @@ +package agentapi_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + agentproto "github.com/coder/coder/v2/agent/proto" + "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/testutil" +) + +// boundaryFixture holds all database prerequisites for boundary log tests. +type boundaryFixture struct { + DB database.Store + AgentID uuid.UUID + WorkspaceID uuid.UUID + OwnerID uuid.UUID + TemplateID uuid.UUID + TemplateVerID uuid.UUID +} + +// newBoundaryFixture creates the full workspace-agent prerequisite chain needed +// by InsertBoundarySession's FK constraint on workspace_agent_id. +func newBoundaryFixture(t *testing.T) *boundaryFixture { + t.Helper() + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmplVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{Valid: true, UUID: tmpl.ID}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + TemplateID: tmpl.ID, + OwnerID: user.ID, + }) + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + JobID: job.ID, + WorkspaceID: workspace.ID, + TemplateVersionID: tmplVersion.ID, + }) + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: build.JobID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + return &boundaryFixture{ + DB: db, + AgentID: agent.ID, + WorkspaceID: workspace.ID, + OwnerID: user.ID, + TemplateID: tmpl.ID, + TemplateVerID: tmplVersion.ID, + } +} + +// api returns a new BoundaryLogsAPI backed by this fixture's database. +func (f *boundaryFixture) api(t *testing.T) *agentapi.BoundaryLogsAPI { + return &agentapi.BoundaryLogsAPI{ + Log: testutil.Logger(t), + Database: f.DB, + AgentID: f.AgentID, + WorkspaceID: f.WorkspaceID, + OwnerID: f.OwnerID, + TemplateID: f.TemplateID, + TemplateVersionID: f.TemplateVerID, + } +} + +// preCreateSession inserts a boundary session directly, bypassing ensureSession, +// to simulate a session created by a prior request or a different coderd replica. +func (f *boundaryFixture) preCreateSession(t *testing.T, sessionID uuid.UUID, process string) { + t.Helper() + _, err := f.DB.InsertBoundarySession(context.Background(), database.InsertBoundarySessionParams{ + ID: sessionID, + WorkspaceAgentID: f.AgentID, + ConfinedProcessName: process, + StartedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + OwnerID: uuid.NullUUID{UUID: f.OwnerID, Valid: true}, + }) + require.NoError(t, err, "pre-create boundary session") +} + +// addAgent creates another workspace agent in the same workspace chain, +// allowing tests to simulate multiple agents sharing one database. +func (f *boundaryFixture) addAgent(t *testing.T) uuid.UUID { + t.Helper() + job := dbgen.ProvisionerJob(t, f.DB, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + build := dbgen.WorkspaceBuild(t, f.DB, database.WorkspaceBuild{ + JobID: job.ID, + WorkspaceID: f.WorkspaceID, + BuildNumber: 2, + TemplateVersionID: f.TemplateVerID, + }) + resource := dbgen.WorkspaceResource(t, f.DB, database.WorkspaceResource{ + JobID: build.JobID, + }) + agent := dbgen.WorkspaceAgent(t, f.DB, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + return agent.ID +} + +func TestReportBoundaryLogs(t *testing.T) { + t.Parallel() + + t.Run("PersistsSessionAndLogs", func(t *testing.T) { + t.Parallel() + + // Given: a fresh database and two HTTP log entries (one allowed, one denied). + f := newBoundaryFixture(t) + api := f.api(t) + sessionID := uuid.New() + now := dbtime.Now() + + // When: boundary logs are reported. + resp, err := api.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: sessionID.String(), + ConfinedProcessName: "claude-code", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.New(now), + SequenceNumber: 0, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://example.com", + MatchedRule: "domain=example.com", + }, + }, + }, + { + Allowed: false, + Time: timestamppb.New(now), + SequenceNumber: 1, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "POST", + Url: "https://evil.com/exfil", + }, + }, + }, + }, + }) + + // Then: one boundary_sessions row and two boundary_logs rows are written. + require.NoError(t, err) + require.NotNil(t, resp) + + sess, err := f.DB.GetBoundarySessionByID(context.Background(), sessionID) + require.NoError(t, err) + require.Equal(t, sessionID, sess.ID) + require.Equal(t, f.AgentID, sess.WorkspaceAgentID) + require.Equal(t, "claude-code", sess.ConfinedProcessName) + + logs, err := f.DB.ListBoundaryLogsBySessionID(context.Background(), database.ListBoundaryLogsBySessionIDParams{ + SessionID: sessionID, + }) + require.NoError(t, err) + require.Len(t, logs, 2) + + require.Equal(t, int32(0), logs[0].SequenceNumber) + require.Equal(t, "http", logs[0].Proto) + require.Equal(t, "GET", logs[0].Method) + require.Equal(t, "https://example.com", logs[0].Detail) + require.Equal(t, "domain=example.com", logs[0].MatchedRule.String) + + require.Equal(t, int32(1), logs[1].SequenceNumber) + require.Equal(t, "http", logs[1].Proto) + require.Equal(t, "POST", logs[1].Method) + require.Equal(t, "https://evil.com/exfil", logs[1].Detail) + require.Equal(t, "", logs[1].MatchedRule.String) + }) + + t.Run("SessionAlreadyExistsSameInstance", func(t *testing.T) { + t.Parallel() + + // Given: a session created during an earlier batch from the same + // BoundaryLogsAPI instance (e.g. the normal second-and-beyond batch path). + f := newBoundaryFixture(t) + api := f.api(t) + sessionID := uuid.New() + f.preCreateSession(t, sessionID, "claude-code") + + // When: a subsequent batch arrives for the same session. + resp, err := api.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: sessionID.String(), + ConfinedProcessName: "claude-code", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.New(dbtime.Now()), + SequenceNumber: 5, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://github.com", + MatchedRule: "domain=github.com", + }, + }, + }, + }, + }) + + // Then: no duplicate session row is created and the new log is persisted. + require.NoError(t, err) + require.NotNil(t, resp) + + _, err = f.DB.GetBoundarySessionByID(context.Background(), sessionID) + require.NoError(t, err) + + logs, err := f.DB.ListBoundaryLogsBySessionID(context.Background(), database.ListBoundaryLogsBySessionIDParams{ + SessionID: sessionID, + }) + require.NoError(t, err) + require.Len(t, logs, 1) + require.Equal(t, int32(5), logs[0].SequenceNumber) + }) + + t.Run("SessionAlreadyExistsDifferentInstance", func(t *testing.T) { + t.Parallel() + + // Given: a session created by a first BoundaryLogsAPI instance (first + // coderd replica). A second instance backed by the same database receives + // logs for the same session ID. + f := newBoundaryFixture(t) + api1 := f.api(t) + api2 := f.api(t) // independent struct, simulates a different coderd replica + sessionID := uuid.New() + now := dbtime.Now() + + // api1 processes the first batch and creates the session. + _, err := api1.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: sessionID.String(), + ConfinedProcessName: "codex", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.New(now), + SequenceNumber: 0, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://openai.com", + }, + }, + }, + }, + }) + require.NoError(t, err) + + // When: api2 processes a subsequent batch for the same session. + resp, err := api2.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: sessionID.String(), + ConfinedProcessName: "codex", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: false, + Time: timestamppb.New(now), + SequenceNumber: 1, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "POST", + Url: "https://pastebin.com", + }, + }, + }, + }, + }) + + // Then: the existing session is reused and both log batches are persisted. + require.NoError(t, err) + require.NotNil(t, resp) + + _, err = f.DB.GetBoundarySessionByID(context.Background(), sessionID) + require.NoError(t, err, "session must still exist") + + logs, err := f.DB.ListBoundaryLogsBySessionID(context.Background(), database.ListBoundaryLogsBySessionIDParams{ + SessionID: sessionID, + }) + require.NoError(t, err) + require.Len(t, logs, 2, "logs from both instances must be persisted") + }) + + t.Run("MissingSessionIDFallsBackToLogOnly", func(t *testing.T) { + t.Parallel() + + // Given: a real database and a request with no session_id (old boundary client). + f := newBoundaryFixture(t) + api := f.api(t) + + // When: boundary logs are reported without a session_id. + resp, err := api.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.New(dbtime.Now()), + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://example.com", + }, + }, + }, + }, + }) + + // Then: the request succeeds (log-only mode) and no rows are persisted. + require.NoError(t, err) + require.NotNil(t, resp) + + logs, err := f.DB.ListBoundaryLogsBySessionID(context.Background(), database.ListBoundaryLogsBySessionIDParams{ + SessionID: uuid.Nil, + }) + require.NoError(t, err) + require.Empty(t, logs, "no boundary_logs rows should be persisted without a session_id") + }) + + t.Run("InvalidSessionIDFallsBackToLogOnly", func(t *testing.T) { + t.Parallel() + + // Given: a real database and a request with a session_id that is not a valid UUID. + f := newBoundaryFixture(t) + api := f.api(t) + + // When: boundary logs are reported with an invalid session_id. + resp, err := api.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: "not-a-uuid", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.New(dbtime.Now()), + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://example.com", + }, + }, + }, + }, + }) + + // Then: the request succeeds (log-only mode) and no rows are persisted. + require.NoError(t, err) + require.NotNil(t, resp) + + logs, err := f.DB.ListBoundaryLogsBySessionID(context.Background(), database.ListBoundaryLogsBySessionIDParams{ + SessionID: uuid.Nil, + }) + require.NoError(t, err) + require.Empty(t, logs, "no boundary_logs rows should be persisted with an invalid session_id") + }) + + t.Run("SameSessionIDDifferentAgents", func(t *testing.T) { + t.Parallel() + + // Given: two workspace agents in the same workspace, both reporting + // logs with the same session ID. A UUID collision across agents is + // negligible in practice; sessions are namespaced by agent_id at + // query time. The first agent creates the session; the second + // agent's ensureSession hits a unique constraint violation and + // treats it as success. + f := newBoundaryFixture(t) + agent2ID := f.addAgent(t) + + api1 := f.api(t) + api2 := &agentapi.BoundaryLogsAPI{ + Log: testutil.Logger(t), + Database: f.DB, + AgentID: agent2ID, + WorkspaceID: f.WorkspaceID, + OwnerID: f.OwnerID, + TemplateID: f.TemplateID, + TemplateVersionID: f.TemplateVerID, + } + + sessionID := uuid.New() + now := dbtime.Now() + + // When: agent1 reports the first batch, creating the session. + _, err := api1.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: sessionID.String(), + ConfinedProcessName: "claude-code", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: true, + Time: timestamppb.New(now), + SequenceNumber: 0, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "GET", + Url: "https://example.com", + }, + }, + }, + }, + }) + require.NoError(t, err) + + // When: agent2 reports a batch with the same session ID. + // ensureSession should hit the unique violation and treat it as success. + resp, err := api2.ReportBoundaryLogs(context.Background(), &agentproto.ReportBoundaryLogsRequest{ + SessionId: sessionID.String(), + ConfinedProcessName: "claude-code", + Logs: []*agentproto.BoundaryLog{ + { + Allowed: false, + Time: timestamppb.New(now), + SequenceNumber: 1, + Resource: &agentproto.BoundaryLog_HttpRequest_{ + HttpRequest: &agentproto.BoundaryLog_HttpRequest{ + Method: "POST", + Url: "https://evil.com/exfil", + }, + }, + }, + }, + }) + + // Then: both agents' logs are persisted under the same session. + require.NoError(t, err) + require.NotNil(t, resp) + + sess, err := f.DB.GetBoundarySessionByID(context.Background(), sessionID) + require.NoError(t, err) + require.Equal(t, f.AgentID, sess.WorkspaceAgentID, "session belongs to the first agent that created it") + + logs, err := f.DB.ListBoundaryLogsBySessionID(context.Background(), database.ListBoundaryLogsBySessionIDParams{ + SessionID: sessionID, + }) + require.NoError(t, err) + require.Len(t, logs, 2, "logs from both agents must be persisted") + }) +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d27bcbc518..646f9dd017 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5710,12 +5710,7 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo } func (q *querier) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { - session, err := q.db.GetBoundarySessionByID(ctx, arg.SessionID) - if err != nil { - return nil, xerrors.Errorf("get boundary session for owner: %w", err) - } - if err := q.authorizeContext(ctx, policy.ActionCreate, - rbac.ResourceBoundaryLog.WithOwner(session.OwnerID.UUID.String())); err != nil { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceBoundaryLog); err != nil { return nil, err } return q.db.InsertBoundaryLogs(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e9c7a8dad2..711263d8e8 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -460,22 +460,13 @@ func (s *MethodTestSuite) TestBoundaryLogs() { dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.BoundarySession{}, nil).AnyTimes() check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) - s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - ownerID := uuid.New() - sessionID := uuid.New() - session := database.BoundarySession{ - ID: sessionID, - OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, - } + s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.InsertBoundaryLogsParams{ - SessionID: sessionID, + SessionID: uuid.New(), ID: []uuid.UUID{uuid.New(), uuid.New()}, } - dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), sessionID).Return(session, nil).AnyTimes() dbm.EXPECT().InsertBoundaryLogs(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() - check.Args(arg).Asserts( - rbac.ResourceBoundaryLog.WithOwner(ownerID.String()), policy.ActionCreate, - ) + check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionCreate) })) s.Run("GetBoundaryLogByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetBoundaryLogByID(gomock.Any(), uuid.Nil).Return(database.BoundaryLog{}, nil).AnyTimes() @@ -486,6 +477,7 @@ func (s *MethodTestSuite) TestBoundaryLogs() { dbm.EXPECT().ListBoundaryLogsBySessionID(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) + s.Run("DeleteOldBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes() check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 05d293bf4b..1edf84aae7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -4798,9 +4798,6 @@ ALTER TABLE ONLY aibridge_interceptions ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE ONLY boundary_logs - ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; - ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 159040d142..4b2e6f4e2e 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -12,7 +12,6 @@ const ( ForeignKeyAiSeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyBoundaryLogsSessionID ForeignKeyConstraint = "boundary_logs_session_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000520_drop_boundary_logs_session_fk.down.sql b/coderd/database/migrations/000520_drop_boundary_logs_session_fk.down.sql new file mode 100644 index 0000000000..ecacec5eb6 --- /dev/null +++ b/coderd/database/migrations/000520_drop_boundary_logs_session_fk.down.sql @@ -0,0 +1,10 @@ +-- Delete orphaned logs that have no matching session before restoring +-- the FK constraint. +DELETE FROM boundary_logs bl +WHERE NOT EXISTS ( + SELECT 1 FROM boundary_sessions bs WHERE bs.id = bl.session_id +); + +ALTER TABLE boundary_logs + ADD CONSTRAINT boundary_logs_session_id_fkey + FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000520_drop_boundary_logs_session_fk.up.sql b/coderd/database/migrations/000520_drop_boundary_logs_session_fk.up.sql new file mode 100644 index 0000000000..58c4452893 --- /dev/null +++ b/coderd/database/migrations/000520_drop_boundary_logs_session_fk.up.sql @@ -0,0 +1,6 @@ +-- Drop the foreign key so that boundary logs can be inserted before +-- the session row exists. The session is created lazily and may fail +-- on transient errors; removing the FK lets logs persist regardless. +-- The session row will be created on a subsequent batch, retroactively +-- linking the orphaned logs via session_id. +ALTER TABLE boundary_logs DROP CONSTRAINT boundary_logs_session_id_fkey; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index f823ecfeb5..63b367a6a5 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -1023,10 +1023,3 @@ type UpsertConnectionLogParams struct { func (r GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow) RBACObject() rbac.Object { return r.WorkspaceTable.RBACObject() } - -func (s BoundarySession) RBACObject() rbac.Object { - if s.OwnerID.Valid { - return rbac.ResourceBoundaryLog.WithOwner(s.OwnerID.UUID.String()) - } - return rbac.ResourceBoundaryLog -} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 48088d112b..3946dd8ecd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3682,7 +3682,7 @@ SELECT unnest($6 :: text[]), unnest($7 :: text[]), unnest($8 :: text[]), - unnest($9 :: text[]) + NULLIF(unnest($9 :: text[]), '') RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule ` diff --git a/coderd/database/queries/boundarylogs.sql b/coderd/database/queries/boundarylogs.sql index 3abeb618a5..c75befa75b 100644 --- a/coderd/database/queries/boundarylogs.sql +++ b/coderd/database/queries/boundarylogs.sql @@ -39,7 +39,7 @@ SELECT unnest(@proto :: text[]), unnest(@method :: text[]), unnest(@detail :: text[]), - unnest(@matched_rule :: text[]) + NULLIF(unnest(@matched_rule :: text[]), '') RETURNING *; -- name: GetBoundaryLogByID :one diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json index a3ce227430..0d88784c09 100644 --- a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json @@ -33,11 +33,11 @@ "schema_version": 1, "values": { "access_port": 443, - "access_url": "https://dev.coder.com/", + "access_url": "https://mydeployment.coder.com", "id": "f8c4851f-dcbd-48bc-9a14-3fd506f8f015", "is_prebuild": false, "is_prebuild_claim": false, - "name": "ai-task-plan-check", + "name": "default", "prebuild_count": 0, "start_count": 1, "template_id": "", diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.dot b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.dot new file mode 100644 index 0000000000..c36ff53236 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.dot @@ -0,0 +1,20 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.json b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.json new file mode 100644 index 0000000000..ce16071462 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfstate.json @@ -0,0 +1,75 @@ +{ + "format_version": "1.0", + "terraform_version": "1.15.5", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "dd55eb9e-dcf2-4a01-ad70-06118d626188", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://mydeployment.coder.com", + "id": "8324ba11-3a81-422b-8c92-fef111777f47", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "default", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "default", + "groups": [], + "id": "ffe8b59f-8833-4622-8cbd-d34549c5f176", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "oidc_access_token": true, + "rbac_roles": [], + "session_token": true, + "ssh_private_key": true + } + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.state.golden b/provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.state.golden new file mode 100644 index 0000000000..546cb9a6e0 --- /dev/null +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/converted_state.state.golden @@ -0,0 +1,9 @@ +{ + "Resources": [], + "Parameters": [], + "Presets": [], + "ExternalAuthProviders": [], + "AITasks": [], + "HasAITasks": false, + "HasExternalAgents": false +}