package agentapi_test import ( "context" "database/sql" "strings" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "google.golang.org/protobuf/types/known/timestamppb" "cdr.dev/slog/sloggers/slogtest" 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/dbmock" "github.com/coder/coder/v2/coderd/database/dbtime" ) func TestUpdateLifecycle(t *testing.T) { t.Parallel() someTime, err := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") require.NoError(t, err) someTime = dbtime.Time(someTime) now := dbtime.Now() var ( workspaceID = uuid.New() agentCreated = database.WorkspaceAgent{ ID: uuid.New(), LifecycleState: database.WorkspaceAgentLifecycleStateCreated, StartedAt: sql.NullTime{Valid: false}, ReadyAt: sql.NullTime{Valid: false}, } agentStarting = database.WorkspaceAgent{ ID: uuid.New(), LifecycleState: database.WorkspaceAgentLifecycleStateStarting, StartedAt: sql.NullTime{Valid: true, Time: someTime}, ReadyAt: sql.NullTime{Valid: false}, } ) t.Run("OKStarting", func(t *testing.T) { t.Parallel() lifecycle := &agentproto.Lifecycle{ State: agentproto.Lifecycle_STARTING, ChangedAt: timestamppb.New(now), } dbM := dbmock.NewMockStore(gomock.NewController(t)) dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: agentCreated.ID, LifecycleState: database.WorkspaceAgentLifecycleStateStarting, StartedAt: sql.NullTime{ Time: now, Valid: true, }, ReadyAt: sql.NullTime{Valid: false}, }).Return(nil) publishCalled := false api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agentCreated, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), PublishWorkspaceUpdateFn: func(ctx context.Context, agent *database.WorkspaceAgent) error { publishCalled = true return nil }, } resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{ Lifecycle: lifecycle, }) require.NoError(t, err) require.Equal(t, lifecycle, resp) require.True(t, publishCalled) }) t.Run("OKReadying", func(t *testing.T) { t.Parallel() lifecycle := &agentproto.Lifecycle{ State: agentproto.Lifecycle_READY, ChangedAt: timestamppb.New(now), } dbM := dbmock.NewMockStore(gomock.NewController(t)) dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: agentStarting.ID, LifecycleState: database.WorkspaceAgentLifecycleStateReady, StartedAt: agentStarting.StartedAt, ReadyAt: sql.NullTime{ Time: now, Valid: true, }, }).Return(nil) api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agentStarting, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), // Test that nil publish fn works. PublishWorkspaceUpdateFn: nil, } resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{ Lifecycle: lifecycle, }) require.NoError(t, err) require.Equal(t, lifecycle, resp) }) // This test jumps from CREATING to READY, skipping STARTED. Both the // StartedAt and ReadyAt fields should be set. t.Run("OKStraightToReady", func(t *testing.T) { t.Parallel() lifecycle := &agentproto.Lifecycle{ State: agentproto.Lifecycle_READY, ChangedAt: timestamppb.New(now), } dbM := dbmock.NewMockStore(gomock.NewController(t)) dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: agentCreated.ID, LifecycleState: database.WorkspaceAgentLifecycleStateReady, StartedAt: sql.NullTime{ Time: now, Valid: true, }, ReadyAt: sql.NullTime{ Time: now, Valid: true, }, }).Return(nil) publishCalled := false api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agentCreated, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), PublishWorkspaceUpdateFn: func(ctx context.Context, agent *database.WorkspaceAgent) error { publishCalled = true return nil }, } resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{ Lifecycle: lifecycle, }) require.NoError(t, err) require.Equal(t, lifecycle, resp) require.True(t, publishCalled) }) t.Run("NoTimeSpecified", func(t *testing.T) { t.Parallel() lifecycle := &agentproto.Lifecycle{ State: agentproto.Lifecycle_READY, // Zero time ChangedAt: timestamppb.New(time.Time{}), } dbM := dbmock.NewMockStore(gomock.NewController(t)) now := dbtime.Now() dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: agentCreated.ID, LifecycleState: database.WorkspaceAgentLifecycleStateReady, StartedAt: sql.NullTime{ Time: now, Valid: true, }, ReadyAt: sql.NullTime{ Time: now, Valid: true, }, }) api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agentCreated, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), PublishWorkspaceUpdateFn: nil, TimeNowFn: func() time.Time { return now }, } resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{ Lifecycle: lifecycle, }) require.NoError(t, err) require.Equal(t, lifecycle, resp) }) t.Run("AllStates", func(t *testing.T) { t.Parallel() agent := database.WorkspaceAgent{ ID: uuid.New(), LifecycleState: database.WorkspaceAgentLifecycleState(""), StartedAt: sql.NullTime{Valid: false}, ReadyAt: sql.NullTime{Valid: false}, } dbM := dbmock.NewMockStore(gomock.NewController(t)) var publishCalled int64 api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agent, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), PublishWorkspaceUpdateFn: func(ctx context.Context, agent *database.WorkspaceAgent) error { atomic.AddInt64(&publishCalled, 1) return nil }, } states := []agentproto.Lifecycle_State{ agentproto.Lifecycle_CREATED, agentproto.Lifecycle_STARTING, agentproto.Lifecycle_START_TIMEOUT, agentproto.Lifecycle_START_ERROR, agentproto.Lifecycle_READY, agentproto.Lifecycle_SHUTTING_DOWN, agentproto.Lifecycle_SHUTDOWN_TIMEOUT, agentproto.Lifecycle_SHUTDOWN_ERROR, agentproto.Lifecycle_OFF, } for i, state := range states { t.Log("state", state) // Use a time after the last state change to ensure ordering. stateNow := now.Add(time.Hour * time.Duration(i)) lifecycle := &agentproto.Lifecycle{ State: state, ChangedAt: timestamppb.New(stateNow), } expectedStartedAt := agent.StartedAt expectedReadyAt := agent.ReadyAt if state == agentproto.Lifecycle_STARTING { expectedStartedAt = sql.NullTime{Valid: true, Time: stateNow} } if state == agentproto.Lifecycle_READY || state == agentproto.Lifecycle_START_ERROR { expectedReadyAt = sql.NullTime{Valid: true, Time: stateNow} } dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{ ID: agent.ID, LifecycleState: database.WorkspaceAgentLifecycleState(strings.ToLower(state.String())), StartedAt: expectedStartedAt, ReadyAt: expectedReadyAt, }).Times(1).Return(nil) resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{ Lifecycle: lifecycle, }) require.NoError(t, err) require.Equal(t, lifecycle, resp) require.Equal(t, int64(i+1), atomic.LoadInt64(&publishCalled)) // For future iterations: agent.StartedAt = expectedStartedAt agent.ReadyAt = expectedReadyAt } }) t.Run("UnknownLifecycleState", func(t *testing.T) { t.Parallel() lifecycle := &agentproto.Lifecycle{ State: -999, ChangedAt: timestamppb.New(now), } dbM := dbmock.NewMockStore(gomock.NewController(t)) publishCalled := false api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agentCreated, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), PublishWorkspaceUpdateFn: func(ctx context.Context, agent *database.WorkspaceAgent) error { publishCalled = true return nil }, } resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{ Lifecycle: lifecycle, }) require.Error(t, err) require.ErrorContains(t, err, "unknown lifecycle state") require.Nil(t, resp) require.False(t, publishCalled) }) } func TestUpdateStartup(t *testing.T) { t.Parallel() var ( workspaceID = uuid.New() agent = database.WorkspaceAgent{ ID: uuid.New(), } ) t.Run("OK", func(t *testing.T) { t.Parallel() dbM := dbmock.NewMockStore(gomock.NewController(t)) api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agent, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), // Not used by UpdateStartup. PublishWorkspaceUpdateFn: nil, } startup := &agentproto.Startup{ Version: "v1.2.3", ExpandedDirectory: "/path/to/expanded/dir", Subsystems: []agentproto.Startup_Subsystem{ agentproto.Startup_ENVBOX, agentproto.Startup_ENVBUILDER, agentproto.Startup_EXECTRACE, }, } dbM.EXPECT().UpdateWorkspaceAgentStartupByID(gomock.Any(), database.UpdateWorkspaceAgentStartupByIDParams{ ID: agent.ID, Version: startup.Version, ExpandedDirectory: startup.ExpandedDirectory, Subsystems: []database.WorkspaceAgentSubsystem{ database.WorkspaceAgentSubsystemEnvbox, database.WorkspaceAgentSubsystemEnvbuilder, database.WorkspaceAgentSubsystemExectrace, }, APIVersion: "2.0", }).Return(nil) ctx := agentapi.WithAPIVersion(context.Background(), "2.0") resp, err := api.UpdateStartup(ctx, &agentproto.UpdateStartupRequest{ Startup: startup, }) require.NoError(t, err) require.Equal(t, startup, resp) }) t.Run("BadVersion", func(t *testing.T) { t.Parallel() dbM := dbmock.NewMockStore(gomock.NewController(t)) api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agent, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), // Not used by UpdateStartup. PublishWorkspaceUpdateFn: nil, } startup := &agentproto.Startup{ Version: "asdf", ExpandedDirectory: "/path/to/expanded/dir", Subsystems: []agentproto.Startup_Subsystem{}, } ctx := agentapi.WithAPIVersion(context.Background(), "2.0") resp, err := api.UpdateStartup(ctx, &agentproto.UpdateStartupRequest{ Startup: startup, }) require.Error(t, err) require.ErrorContains(t, err, "invalid agent semver version") require.Nil(t, resp) }) t.Run("BadSubsystem", func(t *testing.T) { t.Parallel() dbM := dbmock.NewMockStore(gomock.NewController(t)) api := &agentapi.LifecycleAPI{ AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) { return agent, nil }, WorkspaceIDFn: func(ctx context.Context, agent *database.WorkspaceAgent) (uuid.UUID, error) { return workspaceID, nil }, Database: dbM, Log: slogtest.Make(t, nil), // Not used by UpdateStartup. PublishWorkspaceUpdateFn: nil, } startup := &agentproto.Startup{ Version: "v1.2.3", ExpandedDirectory: "/path/to/expanded/dir", Subsystems: []agentproto.Startup_Subsystem{ agentproto.Startup_ENVBOX, -999, }, } ctx := agentapi.WithAPIVersion(context.Background(), "2.0") resp, err := api.UpdateStartup(ctx, &agentproto.UpdateStartupRequest{ Startup: startup, }) require.Error(t, err) require.ErrorContains(t, err, "invalid agent subsystem") require.Nil(t, resp) }) }