@@ -3815,3 +3815,259 @@ func TestDevcontainerDiscovery(t *testing.T) {
3815
3815
}
3816
3816
})
3817
3817
}
3818
+
3819
+ // TestDevcontainerPrebuildSupport validates that devcontainers survive the transition
3820
+ // from prebuild to claimed workspace, ensuring the existing container is reused
3821
+ // with updated configuration rather than being recreated.
3822
+ func TestDevcontainerPrebuildSupport (t * testing.T ) {
3823
+ t .Parallel ()
3824
+
3825
+ var (
3826
+ ctx = testutil .Context (t , testutil .WaitShort )
3827
+ logger = testutil .Logger (t )
3828
+ mClock = quartz .NewMock (t )
3829
+ tickerTrap = mClock .Trap ().TickerFunc ("updaterLoop" )
3830
+
3831
+ mCtrl = gomock .NewController (t )
3832
+ mCCLI = acmock .NewMockContainerCLI (mCtrl )
3833
+ mDCCLI = acmock .NewMockDevcontainerCLI (mCtrl )
3834
+
3835
+ fSAC = & fakeSubAgentClient {}
3836
+
3837
+ testDC = codersdk.WorkspaceAgentDevcontainer {
3838
+ ID : uuid .New (),
3839
+ WorkspaceFolder : "/home/coder/coder" ,
3840
+ ConfigPath : "/home/coder/coder/.devcontainer/devcontainer.json" ,
3841
+ }
3842
+ testContainer = newFakeContainer ("test-container-id" , testDC .ConfigPath , testDC .WorkspaceFolder )
3843
+
3844
+ prebuildOwner = "prebuilds"
3845
+ prebuildWorkspace = "prebuilds-xyz-123"
3846
+ prebuildAppURL = "prebuilds.zed"
3847
+
3848
+ userOwner = "user"
3849
+ userWorkspace = "user-workspace"
3850
+ userAppURL = "user.zed"
3851
+ )
3852
+
3853
+ coderBin , err := os .Executable ()
3854
+ require .NoError (t , err )
3855
+
3856
+ // ==================================================
3857
+ // PHASE 1: Prebuild workspace creates devcontainer
3858
+ // ==================================================
3859
+
3860
+ // Given: There are no containers initially.
3861
+ mCCLI .EXPECT ().List (gomock .Any ()).Return (codersdk.WorkspaceAgentListContainersResponse {
3862
+ Containers : []codersdk.WorkspaceAgentContainer {},
3863
+ }, nil )
3864
+
3865
+ api := agentcontainers .NewAPI (logger ,
3866
+ // We want this first `agentcontainers.API` to have a manifest info
3867
+ // that is consistent with what a prebuild workspace would have.
3868
+ agentcontainers .WithManifestInfo (prebuildOwner , prebuildWorkspace , "dev" , "/home/coder" ),
3869
+ // Given: We start with a single dev container resource.
3870
+ agentcontainers .WithDevcontainers (
3871
+ []codersdk.WorkspaceAgentDevcontainer {testDC },
3872
+ []codersdk.WorkspaceAgentScript {{ID : testDC .ID , LogSourceID : uuid .New ()}},
3873
+ ),
3874
+ agentcontainers .WithSubAgentClient (fSAC ),
3875
+ agentcontainers .WithContainerCLI (mCCLI ),
3876
+ agentcontainers .WithDevcontainerCLI (mDCCLI ),
3877
+ agentcontainers .WithClock (mClock ),
3878
+ agentcontainers .WithWatcher (watcher .NewNoop ()),
3879
+ )
3880
+ api .Start ()
3881
+
3882
+ tickerTrap .MustWait (ctx ).MustRelease (ctx )
3883
+ tickerTrap .Close ()
3884
+
3885
+ // Given: We allow the dev container to be created.
3886
+ mDCCLI .EXPECT ().Up (gomock .Any (), testDC .WorkspaceFolder , testDC .ConfigPath , gomock .Any ()).
3887
+ Return ("test-container-id" , nil )
3888
+
3889
+ mCCLI .EXPECT ().List (gomock .Any ()).Return (codersdk.WorkspaceAgentListContainersResponse {
3890
+ Containers : []codersdk.WorkspaceAgentContainer {testContainer },
3891
+ }, nil )
3892
+
3893
+ gomock .InOrder (
3894
+ mCCLI .EXPECT ().DetectArchitecture (gomock .Any (), "test-container-id" ).Return (runtime .GOARCH , nil ),
3895
+
3896
+ // Verify prebuild environment variables are passed to devcontainer
3897
+ mDCCLI .EXPECT ().ReadConfig (gomock .Any (),
3898
+ testDC .WorkspaceFolder ,
3899
+ testDC .ConfigPath ,
3900
+ gomock .Cond (func (envs []string ) bool {
3901
+ return slices .Contains (envs , "CODER_WORKSPACE_OWNER_NAME=" + prebuildOwner ) &&
3902
+ slices .Contains (envs , "CODER_WORKSPACE_NAME=" + prebuildWorkspace )
3903
+ }),
3904
+ ).Return (agentcontainers.DevcontainerConfig {
3905
+ MergedConfiguration : agentcontainers.DevcontainerMergedConfiguration {
3906
+ Customizations : agentcontainers.DevcontainerMergedCustomizations {
3907
+ Coder : []agentcontainers.CoderCustomization {
3908
+ agentcontainers.CoderCustomization {
3909
+ Apps : []agentcontainers.SubAgentApp {
3910
+ agentcontainers.SubAgentApp {
3911
+ Slug : "zed" ,
3912
+ URL : prebuildAppURL ,
3913
+ },
3914
+ },
3915
+ },
3916
+ },
3917
+ },
3918
+ },
3919
+ }, nil ),
3920
+
3921
+ mCCLI .EXPECT ().ExecAs (gomock .Any (), testContainer .ID , "root" , "mkdir" , "-p" , "/.coder-agent" ).Return (nil , nil ),
3922
+ mCCLI .EXPECT ().Copy (gomock .Any (), testContainer .ID , coderBin , "/.coder-agent/coder" ).Return (nil ),
3923
+ mCCLI .EXPECT ().ExecAs (gomock .Any (), testContainer .ID , "root" , "chmod" , "0755" , "/.coder-agent" , "/.coder-agent/coder" ).Return (nil , nil ),
3924
+ mCCLI .EXPECT ().ExecAs (gomock .Any (), testContainer .ID , "root" , "/bin/sh" , "-c" , "chown $(id -u):$(id -g) /.coder-agent/coder" ).Return (nil , nil ),
3925
+
3926
+ // We want to mock how the `Exec` function works when starting an agent. This should
3927
+ // run until the given `ctx` is done.
3928
+ mDCCLI .EXPECT ().Exec (gomock .Any (),
3929
+ testDC .WorkspaceFolder , testDC .ConfigPath ,
3930
+ "/.coder-agent/coder" , []string {"agent" }, gomock .Any (), gomock .Any (),
3931
+ ).Do (func (ctx context.Context , _ , _ , _ string , _ []string , _ ... agentcontainers.DevcontainerCLIExecOptions ) error {
3932
+ select {
3933
+ case <- ctx .Done ():
3934
+ return nil
3935
+ }
3936
+ }),
3937
+ )
3938
+
3939
+ // When: We create the dev container resource
3940
+ err = api .CreateDevcontainer (testDC .WorkspaceFolder , testDC .ConfigPath )
3941
+ require .NoError (t , err )
3942
+
3943
+ // Then: We there to be only 1 agent.
3944
+ require .Len (t , fSAC .agents , 1 )
3945
+
3946
+ // And: We expect only 1 agent to have been created.
3947
+ require .Len (t , fSAC .created , 1 )
3948
+ firstAgent := fSAC .created [0 ]
3949
+
3950
+ // And: We expect this agent to be the current agent.
3951
+ _ , found := fSAC .agents [firstAgent .ID ]
3952
+ require .True (t , found , "first agent expected to be current agent" )
3953
+
3954
+ // And: We expect there to be a single app.
3955
+ require .Len (t , firstAgent .Apps , 1 )
3956
+ firstApp := firstAgent .Apps [0 ]
3957
+
3958
+ // And: We expect this app to have the pre-claim URL.
3959
+ require .Equal (t , prebuildAppURL , firstApp .URL )
3960
+
3961
+ // Given: We now close the API
3962
+ api .Close ()
3963
+
3964
+ // =============================================================
3965
+ // PHASE 2: User claims workspace, devcontainer should be reused
3966
+ // =============================================================
3967
+
3968
+ // Given: We have a running container.
3969
+ mCCLI .EXPECT ().List (gomock .Any ()).Return (codersdk.WorkspaceAgentListContainersResponse {
3970
+ Containers : []codersdk.WorkspaceAgentContainer {testContainer },
3971
+ }, nil )
3972
+
3973
+ mClock = quartz .NewMock (t )
3974
+ tickerTrap = mClock .Trap ().TickerFunc ("updaterLoop" )
3975
+
3976
+ // Given: We create a new claimed API
3977
+ api = agentcontainers .NewAPI (logger ,
3978
+ // We want this second `agentcontainers.API` to have a manifest info
3979
+ // that is consistent with what a claimed workspace would have.
3980
+ agentcontainers .WithManifestInfo (userOwner , userWorkspace , "dev" , "/home/coder" ),
3981
+ // Given: We start with a single dev container resource.
3982
+ agentcontainers .WithDevcontainers (
3983
+ []codersdk.WorkspaceAgentDevcontainer {testDC },
3984
+ []codersdk.WorkspaceAgentScript {{ID : testDC .ID , LogSourceID : uuid .New ()}},
3985
+ ),
3986
+ agentcontainers .WithSubAgentClient (fSAC ),
3987
+ agentcontainers .WithContainerCLI (mCCLI ),
3988
+ agentcontainers .WithDevcontainerCLI (mDCCLI ),
3989
+ agentcontainers .WithClock (mClock ),
3990
+ agentcontainers .WithWatcher (watcher .NewNoop ()),
3991
+ )
3992
+ api .Start ()
3993
+ defer api .Close ()
3994
+
3995
+ tickerTrap .MustWait (ctx ).MustRelease (ctx )
3996
+ tickerTrap .Close ()
3997
+
3998
+ // Given: We allow the dev container to be created.
3999
+ mDCCLI .EXPECT ().Up (gomock .Any (), testDC .WorkspaceFolder , testDC .ConfigPath , gomock .Any ()).
4000
+ Return ("test-container-id" , nil )
4001
+
4002
+ mCCLI .EXPECT ().List (gomock .Any ()).Return (codersdk.WorkspaceAgentListContainersResponse {
4003
+ Containers : []codersdk.WorkspaceAgentContainer {testContainer },
4004
+ }, nil )
4005
+
4006
+ gomock .InOrder (
4007
+ mCCLI .EXPECT ().DetectArchitecture (gomock .Any (), "test-container-id" ).Return (runtime .GOARCH , nil ),
4008
+
4009
+ // Verify claimed workspace environment variables are passed to devcontainer
4010
+ mDCCLI .EXPECT ().ReadConfig (gomock .Any (),
4011
+ testDC .WorkspaceFolder ,
4012
+ testDC .ConfigPath ,
4013
+ gomock .Cond (func (envs []string ) bool {
4014
+ return slices .Contains (envs , "CODER_WORKSPACE_OWNER_NAME=" + userOwner ) &&
4015
+ slices .Contains (envs , "CODER_WORKSPACE_NAME=" + userWorkspace )
4016
+ }),
4017
+ ).Return (agentcontainers.DevcontainerConfig {
4018
+ MergedConfiguration : agentcontainers.DevcontainerMergedConfiguration {
4019
+ Customizations : agentcontainers.DevcontainerMergedCustomizations {
4020
+ Coder : []agentcontainers.CoderCustomization {
4021
+ agentcontainers.CoderCustomization {
4022
+ Apps : []agentcontainers.SubAgentApp {
4023
+ agentcontainers.SubAgentApp {
4024
+ Slug : "zed" ,
4025
+ URL : userAppURL ,
4026
+ },
4027
+ },
4028
+ },
4029
+ },
4030
+ },
4031
+ },
4032
+ }, nil ),
4033
+
4034
+ mCCLI .EXPECT ().ExecAs (gomock .Any (), testContainer .ID , "root" , "mkdir" , "-p" , "/.coder-agent" ).Return (nil , nil ),
4035
+ mCCLI .EXPECT ().Copy (gomock .Any (), testContainer .ID , coderBin , "/.coder-agent/coder" ).Return (nil ),
4036
+ mCCLI .EXPECT ().ExecAs (gomock .Any (), testContainer .ID , "root" , "chmod" , "0755" , "/.coder-agent" , "/.coder-agent/coder" ).Return (nil , nil ),
4037
+ mCCLI .EXPECT ().ExecAs (gomock .Any (), testContainer .ID , "root" , "/bin/sh" , "-c" , "chown $(id -u):$(id -g) /.coder-agent/coder" ).Return (nil , nil ),
4038
+
4039
+ // We want to mock how the `Exec` function works when starting an agent. This should
4040
+ // run until the given `ctx` is done.
4041
+ mDCCLI .EXPECT ().Exec (gomock .Any (),
4042
+ testDC .WorkspaceFolder , testDC .ConfigPath ,
4043
+ "/.coder-agent/coder" , []string {"agent" }, gomock .Any (), gomock .Any (),
4044
+ ).Do (func (ctx context.Context , _ , _ , _ string , _ []string , _ ... agentcontainers.DevcontainerCLIExecOptions ) error {
4045
+ select {
4046
+ case <- ctx .Done ():
4047
+ return nil
4048
+ }
4049
+ }),
4050
+ )
4051
+
4052
+ // When: We create the dev container resource.
4053
+ err = api .CreateDevcontainer (testDC .WorkspaceFolder , testDC .ConfigPath )
4054
+ require .NoError (t , err )
4055
+
4056
+ // Then: We expect there to be only 1 agent.
4057
+ require .Len (t , fSAC .agents , 1 )
4058
+
4059
+ // And: We expect _a separate agent_ to have been created.
4060
+ require .Len (t , fSAC .created , 2 )
4061
+ secondAgent := fSAC .created [1 ]
4062
+
4063
+ // And: We expect this new agent to be the current agent.
4064
+ _ , found = fSAC .agents [secondAgent .ID ]
4065
+ require .True (t , found , "second agent expected to be current agent" )
4066
+
4067
+ // And: We expect there to be a single app.
4068
+ require .Len (t , secondAgent .Apps , 1 )
4069
+ secondApp := secondAgent .Apps [0 ]
4070
+
4071
+ // And: We expect this app to have the post-claim URL.
4072
+ require .Equal (t , userAppURL , secondApp .URL )
4073
+ }
0 commit comments