Skip to content

Commit 4756080

Browse files
feat(site): display devcontainer start error (#18637)
Fixes coder/internal#705 Surface errors on the UI when a devcontainer agent is unable to be injected.
1 parent fc7700a commit 4756080

File tree

12 files changed

+325
-5
lines changed

12 files changed

+325
-5
lines changed

agent/agentcontainers/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,9 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code
717717
err := api.maybeInjectSubAgentIntoContainerLocked(ctx, dc)
718718
if err != nil {
719719
logger.Error(ctx, "inject subagent into container failed", slog.Error(err))
720+
dc.Error = err.Error()
721+
} else {
722+
dc.Error = ""
720723
}
721724
}
722725

@@ -1032,6 +1035,7 @@ func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...D
10321035
api.mu.Lock()
10331036
dc = api.knownDevcontainers[dc.WorkspaceFolder]
10341037
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusError
1038+
dc.Error = err.Error()
10351039
api.knownDevcontainers[dc.WorkspaceFolder] = dc
10361040
api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "errorTimes")
10371041
api.mu.Unlock()
@@ -1055,6 +1059,7 @@ func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...D
10551059
}
10561060
}
10571061
dc.Dirty = false
1062+
dc.Error = ""
10581063
api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "successTimes")
10591064
api.knownDevcontainers[dc.WorkspaceFolder] = dc
10601065
api.mu.Unlock()

agent/agentcontainers/api_test.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,6 +1649,225 @@ func TestAPI(t *testing.T) {
16491649
assert.Empty(t, fakeSAC.agents)
16501650
})
16511651

1652+
t.Run("Error", func(t *testing.T) {
1653+
t.Parallel()
1654+
1655+
if runtime.GOOS == "windows" {
1656+
t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)")
1657+
}
1658+
1659+
t.Run("DuringUp", func(t *testing.T) {
1660+
t.Parallel()
1661+
1662+
var (
1663+
ctx = testutil.Context(t, testutil.WaitMedium)
1664+
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
1665+
mClock = quartz.NewMock(t)
1666+
fCCLI = &fakeContainerCLI{arch: "<none>"}
1667+
fDCCLI = &fakeDevcontainerCLI{
1668+
upErrC: make(chan error, 1),
1669+
}
1670+
fSAC = &fakeSubAgentClient{
1671+
logger: logger.Named("fakeSubAgentClient"),
1672+
}
1673+
1674+
testDevcontainer = codersdk.WorkspaceAgentDevcontainer{
1675+
ID: uuid.New(),
1676+
Name: "test-devcontainer",
1677+
WorkspaceFolder: "/workspaces/project",
1678+
ConfigPath: "/workspaces/project/.devcontainer/devcontainer.json",
1679+
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
1680+
}
1681+
)
1682+
1683+
mClock.Set(time.Now()).MustWait(ctx)
1684+
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
1685+
nowRecreateErrorTrap := mClock.Trap().Now("recreate", "errorTimes")
1686+
nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes")
1687+
1688+
api := agentcontainers.NewAPI(logger,
1689+
agentcontainers.WithClock(mClock),
1690+
agentcontainers.WithContainerCLI(fCCLI),
1691+
agentcontainers.WithDevcontainerCLI(fDCCLI),
1692+
agentcontainers.WithDevcontainers(
1693+
[]codersdk.WorkspaceAgentDevcontainer{testDevcontainer},
1694+
[]codersdk.WorkspaceAgentScript{{ID: testDevcontainer.ID, LogSourceID: uuid.New()}},
1695+
),
1696+
agentcontainers.WithSubAgentClient(fSAC),
1697+
agentcontainers.WithSubAgentURL("test-subagent-url"),
1698+
agentcontainers.WithWatcher(watcher.NewNoop()),
1699+
)
1700+
api.Start()
1701+
defer func() {
1702+
close(fDCCLI.upErrC)
1703+
api.Close()
1704+
}()
1705+
1706+
r := chi.NewRouter()
1707+
r.Mount("/", api.Routes())
1708+
1709+
tickerTrap.MustWait(ctx).MustRelease(ctx)
1710+
tickerTrap.Close()
1711+
1712+
// Given: We send a 'recreate' request.
1713+
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+testDevcontainer.ID.String()+"/recreate", nil)
1714+
rec := httptest.NewRecorder()
1715+
r.ServeHTTP(rec, req)
1716+
require.Equal(t, http.StatusAccepted, rec.Code)
1717+
1718+
// Given: We simulate an error running `devcontainer up`
1719+
simulatedError := xerrors.New("simulated error")
1720+
testutil.RequireSend(ctx, t, fDCCLI.upErrC, simulatedError)
1721+
1722+
nowRecreateErrorTrap.MustWait(ctx).MustRelease(ctx)
1723+
nowRecreateErrorTrap.Close()
1724+
1725+
req = httptest.NewRequest(http.MethodGet, "/", nil)
1726+
rec = httptest.NewRecorder()
1727+
r.ServeHTTP(rec, req)
1728+
require.Equal(t, http.StatusOK, rec.Code)
1729+
1730+
var response codersdk.WorkspaceAgentListContainersResponse
1731+
err := json.NewDecoder(rec.Body).Decode(&response)
1732+
require.NoError(t, err)
1733+
1734+
// Then: We expect that there will be an error associated with the devcontainer.
1735+
require.Len(t, response.Devcontainers, 1)
1736+
require.Equal(t, "simulated error", response.Devcontainers[0].Error)
1737+
1738+
// Given: We send another 'recreate' request.
1739+
req = httptest.NewRequest(http.MethodPost, "/devcontainers/"+testDevcontainer.ID.String()+"/recreate", nil)
1740+
rec = httptest.NewRecorder()
1741+
r.ServeHTTP(rec, req)
1742+
require.Equal(t, http.StatusAccepted, rec.Code)
1743+
1744+
// Given: We allow `devcontainer up` to succeed.
1745+
testutil.RequireSend(ctx, t, fDCCLI.upErrC, nil)
1746+
1747+
nowRecreateSuccessTrap.MustWait(ctx).MustRelease(ctx)
1748+
nowRecreateSuccessTrap.Close()
1749+
1750+
req = httptest.NewRequest(http.MethodGet, "/", nil)
1751+
rec = httptest.NewRecorder()
1752+
r.ServeHTTP(rec, req)
1753+
require.Equal(t, http.StatusOK, rec.Code)
1754+
1755+
response = codersdk.WorkspaceAgentListContainersResponse{}
1756+
err = json.NewDecoder(rec.Body).Decode(&response)
1757+
require.NoError(t, err)
1758+
1759+
// Then: We expect that there will be no error associated with the devcontainer.
1760+
require.Len(t, response.Devcontainers, 1)
1761+
require.Equal(t, "", response.Devcontainers[0].Error)
1762+
})
1763+
1764+
t.Run("DuringInjection", func(t *testing.T) {
1765+
t.Parallel()
1766+
1767+
var (
1768+
ctx = testutil.Context(t, testutil.WaitMedium)
1769+
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
1770+
mClock = quartz.NewMock(t)
1771+
mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t))
1772+
fDCCLI = &fakeDevcontainerCLI{}
1773+
fSAC = &fakeSubAgentClient{
1774+
logger: logger.Named("fakeSubAgentClient"),
1775+
createErrC: make(chan error, 1),
1776+
}
1777+
1778+
containerCreatedAt = time.Now()
1779+
testContainer = codersdk.WorkspaceAgentContainer{
1780+
ID: "test-container-id",
1781+
FriendlyName: "test-container",
1782+
Image: "test-image",
1783+
Running: true,
1784+
CreatedAt: containerCreatedAt,
1785+
Labels: map[string]string{
1786+
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces",
1787+
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
1788+
},
1789+
}
1790+
)
1791+
1792+
coderBin, err := os.Executable()
1793+
require.NoError(t, err)
1794+
1795+
// Mock the `List` function to always return the test container.
1796+
mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
1797+
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
1798+
}, nil).AnyTimes()
1799+
1800+
// We're going to force the container CLI to fail, which will allow us to test the
1801+
// error handling.
1802+
simulatedError := xerrors.New("simulated error")
1803+
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return("", simulatedError).Times(1)
1804+
1805+
mClock.Set(containerCreatedAt).MustWait(ctx)
1806+
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
1807+
1808+
api := agentcontainers.NewAPI(logger,
1809+
agentcontainers.WithClock(mClock),
1810+
agentcontainers.WithContainerCLI(mCCLI),
1811+
agentcontainers.WithDevcontainerCLI(fDCCLI),
1812+
agentcontainers.WithSubAgentClient(fSAC),
1813+
agentcontainers.WithSubAgentURL("test-subagent-url"),
1814+
agentcontainers.WithWatcher(watcher.NewNoop()),
1815+
)
1816+
api.Start()
1817+
defer func() {
1818+
close(fSAC.createErrC)
1819+
api.Close()
1820+
}()
1821+
1822+
r := chi.NewRouter()
1823+
r.Mount("/", api.Routes())
1824+
1825+
// Given: We allow an attempt at creation to occur.
1826+
tickerTrap.MustWait(ctx).MustRelease(ctx)
1827+
tickerTrap.Close()
1828+
1829+
req := httptest.NewRequest(http.MethodGet, "/", nil)
1830+
rec := httptest.NewRecorder()
1831+
r.ServeHTTP(rec, req)
1832+
require.Equal(t, http.StatusOK, rec.Code)
1833+
1834+
var response codersdk.WorkspaceAgentListContainersResponse
1835+
err = json.NewDecoder(rec.Body).Decode(&response)
1836+
require.NoError(t, err)
1837+
1838+
// Then: We expect that there will be an error associated with the devcontainer.
1839+
require.Len(t, response.Devcontainers, 1)
1840+
require.Equal(t, "detect architecture: simulated error", response.Devcontainers[0].Error)
1841+
1842+
gomock.InOrder(
1843+
mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil),
1844+
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil),
1845+
mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil),
1846+
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil),
1847+
mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "/bin/sh", "-c", "chown $(id -u):$(id -g) /.coder-agent/coder").Return(nil, nil),
1848+
)
1849+
1850+
// Given: We allow creation to succeed.
1851+
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
1852+
1853+
_, aw := mClock.AdvanceNext()
1854+
aw.MustWait(ctx)
1855+
1856+
req = httptest.NewRequest(http.MethodGet, "/", nil)
1857+
rec = httptest.NewRecorder()
1858+
r.ServeHTTP(rec, req)
1859+
require.Equal(t, http.StatusOK, rec.Code)
1860+
1861+
response = codersdk.WorkspaceAgentListContainersResponse{}
1862+
err = json.NewDecoder(rec.Body).Decode(&response)
1863+
require.NoError(t, err)
1864+
1865+
// Then: We expect that the error will be gone
1866+
require.Len(t, response.Devcontainers, 1)
1867+
require.Equal(t, "", response.Devcontainers[0].Error)
1868+
})
1869+
})
1870+
16521871
t.Run("Create", func(t *testing.T) {
16531872
t.Parallel()
16541873

coderd/apidoc/docs.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codersdk/workspaceagents.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ type WorkspaceAgentDevcontainer struct {
417417
Dirty bool `json:"dirty"`
418418
Container *WorkspaceAgentContainer `json:"container,omitempty"`
419419
Agent *WorkspaceAgentDevcontainerAgent `json:"agent,omitempty"`
420+
421+
Error string `json:"error,omitempty"`
420422
}
421423

422424
// WorkspaceAgentDevcontainerAgent represents the sub agent for a

docs/reference/api/agents.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/reference/api/schemas.md

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/modules/resources/AgentDevcontainerCard.stories.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ const meta: Meta<typeof AgentDevcontainerCard> = {
4646
export default meta;
4747
type Story = StoryObj<typeof AgentDevcontainerCard>;
4848

49+
export const HasError: Story = {
50+
args: {
51+
devcontainer: {
52+
...MockWorkspaceAgentDevcontainer,
53+
error: "unable to inject devcontainer with agent",
54+
agent: undefined,
55+
},
56+
},
57+
};
58+
4959
export const NoPorts: Story = {};
5060

5161
export const WithPorts: Story = {

site/src/modules/resources/AgentDevcontainerCard.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
WorkspaceAgentDevcontainer,
77
WorkspaceAgentListContainersResponse,
88
} from "api/typesGenerated";
9+
910
import { Button } from "components/Button/Button";
1011
import { displayError } from "components/GlobalSnackbar/utils";
1112
import { Spinner } from "components/Spinner/Spinner";
@@ -23,11 +24,12 @@ import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
2324
import type { FC } from "react";
2425
import { useEffect } from "react";
2526
import { useMutation, useQueryClient } from "react-query";
27+
import { cn } from "utils/cn";
2628
import { portForwardURL } from "utils/portForward";
2729
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
2830
import { AgentButton } from "./AgentButton";
2931
import { AgentLatency } from "./AgentLatency";
30-
import { SubAgentStatus } from "./AgentStatus";
32+
import { DevcontainerStatus } from "./AgentStatus";
3133
import { PortForwardButton } from "./PortForwardButton";
3234
import { AgentSSHButton } from "./SSHButton/SSHButton";
3335
import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip";
@@ -190,7 +192,10 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
190192
key={devcontainer.id}
191193
direction="column"
192194
spacing={0}
193-
className="relative py-4 border border-dashed border-border rounded"
195+
className={cn(
196+
"relative py-4 border border-dashed border-border rounded",
197+
devcontainer.error && "border-content-destructive border-solid",
198+
)}
194199
>
195200
<div
196201
className="absolute -top-2 left-5
@@ -208,7 +213,11 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
208213
>
209214
<div className="flex items-center gap-6 text-xs text-content-secondary">
210215
<div className="flex items-center gap-4 md:w-full">
211-
<SubAgentStatus agent={subAgent} />
216+
<DevcontainerStatus
217+
devcontainer={devcontainer}
218+
parentAgent={parentAgent}
219+
agent={subAgent}
220+
/>
212221
<span
213222
className="max-w-xs shrink-0
214223
overflow-hidden text-ellipsis whitespace-nowrap
@@ -273,6 +282,12 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
273282
</div>
274283
</header>
275284

285+
{devcontainer.error && (
286+
<div className="px-8 pt-2 text-xs text-content-destructive">
287+
{devcontainer.error}
288+
</div>
289+
)}
290+
276291
{(showSubAgentApps || showSubAgentAppsPlaceholders) && (
277292
<div className="flex flex-col gap-8 px-8 pt-4">
278293
{subAgent &&

0 commit comments

Comments
 (0)