Skip to content

Commit b2833c6

Browse files
authored
feat: update build url to @username/workspace/builds/buildnumber (#2234)
* update build url to @username/workspace/builds/buildnumber * update errors thrown from the API * add unit tests for the new API * add t.parallel * get username and workspace name from params
1 parent f9290b0 commit b2833c6

File tree

16 files changed

+305
-48
lines changed

16 files changed

+305
-48
lines changed

coderd/coderd.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,10 @@ func New(options *Options) *API {
270270
r.Get("/", api.organizationsByUser)
271271
r.Get("/{organizationname}", api.organizationByUserAndName)
272272
})
273-
r.Get("/workspace/{workspacename}", api.workspaceByOwnerAndName)
273+
r.Route("/workspace/{workspacename}", func(r chi.Router) {
274+
r.Get("/", api.workspaceByOwnerAndName)
275+
r.Get("/builds/{buildnumber}", api.workspaceBuildByBuildNumber)
276+
})
274277
r.Get("/gitsshkey", api.gitSSHKey)
275278
r.Put("/gitsshkey", api.regenerateGitSSHKey)
276279
})

coderd/coderd_test.go

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"io"
66
"net/http"
7+
"strconv"
78
"strings"
89
"testing"
910
"time"
@@ -163,6 +164,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
163164
AssertObject: rbac.ResourceWorkspace,
164165
AssertAction: rbac.ActionRead,
165166
},
167+
"GET:/api/v2/users/me/workspace/{workspacename}/builds/{buildnumber}": {
168+
AssertObject: rbac.ResourceWorkspace,
169+
AssertAction: rbac.ActionRead,
170+
},
166171
"GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": {
167172
AssertAction: rbac.ActionRead,
168173
AssertObject: workspaceRBACObj,
@@ -388,6 +393,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
388393
route = strings.ReplaceAll(route, "{workspacename}", workspace.Name)
389394
route = strings.ReplaceAll(route, "{workspacebuildname}", workspace.LatestBuild.Name)
390395
route = strings.ReplaceAll(route, "{workspaceagent}", workspaceResources[0].Agents[0].ID.String())
396+
route = strings.ReplaceAll(route, "{buildnumber}", strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10))
391397
route = strings.ReplaceAll(route, "{template}", template.ID.String())
392398
route = strings.ReplaceAll(route, "{hash}", file.Hash)
393399
route = strings.ReplaceAll(route, "{workspaceresource}", workspaceResources[0].ID.String())

coderd/database/databasefake/databasefake.go

+16
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,22 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndName(_ context.Context, a
625625
return database.WorkspaceBuild{}, sql.ErrNoRows
626626
}
627627

628+
func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Context, arg database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (database.WorkspaceBuild, error) {
629+
q.mutex.RLock()
630+
defer q.mutex.RUnlock()
631+
632+
for _, workspaceBuild := range q.workspaceBuilds {
633+
if workspaceBuild.WorkspaceID.String() != arg.WorkspaceID.String() {
634+
continue
635+
}
636+
if workspaceBuild.BuildNumber != arg.BuildNumber {
637+
continue
638+
}
639+
return workspaceBuild, nil
640+
}
641+
return database.WorkspaceBuild{}, sql.ErrNoRows
642+
}
643+
628644
func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) {
629645
q.mutex.RLock()
630646
defer q.mutex.RUnlock()

coderd/database/querier.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

+35
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspacebuilds.sql

+9
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ WHERE
2727
workspace_id = $1
2828
AND "name" = $2;
2929

30+
-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one
31+
SELECT
32+
*
33+
FROM
34+
workspace_builds
35+
WHERE
36+
workspace_id = $1
37+
AND build_number = $2;
38+
3039
-- name: GetWorkspaceBuildByWorkspaceID :many
3140
SELECT
3241
*

coderd/workspacebuilds.go

+77
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"net/http"
9+
"strconv"
910

1011
"github.com/go-chi/chi/v5"
1112
"github.com/google/uuid"
@@ -160,6 +161,82 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
160161
httpapi.Write(rw, http.StatusOK, apiBuilds)
161162
}
162163

164+
func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Request) {
165+
owner := httpmw.UserParam(r)
166+
workspaceName := chi.URLParam(r, "workspacename")
167+
buildNumber, err := strconv.ParseInt(chi.URLParam(r, "buildnumber"), 10, 32)
168+
if err != nil {
169+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
170+
Message: "Failed to parse build number as integer.",
171+
Detail: err.Error(),
172+
})
173+
return
174+
}
175+
176+
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{
177+
OwnerID: owner.ID,
178+
Name: workspaceName,
179+
})
180+
if errors.Is(err, sql.ErrNoRows) {
181+
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
182+
Message: fmt.Sprintf("Workspace %q does not exist.", workspaceName),
183+
})
184+
return
185+
}
186+
if err != nil {
187+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
188+
Message: "Internal error fetching workspace by name.",
189+
Detail: err.Error(),
190+
})
191+
return
192+
}
193+
194+
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
195+
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
196+
return
197+
}
198+
199+
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(r.Context(), database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
200+
WorkspaceID: workspace.ID,
201+
BuildNumber: int32(buildNumber),
202+
})
203+
if errors.Is(err, sql.ErrNoRows) {
204+
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
205+
Message: fmt.Sprintf("Workspace %q Build %d does not exist.", workspaceName, buildNumber),
206+
})
207+
return
208+
}
209+
if err != nil {
210+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
211+
Message: "Internal error fetching workspace build.",
212+
Detail: err.Error(),
213+
})
214+
return
215+
}
216+
217+
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
218+
if err != nil {
219+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
220+
Message: "Internal error fetching provisioner job.",
221+
Detail: err.Error(),
222+
})
223+
return
224+
}
225+
226+
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID})
227+
if err != nil {
228+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
229+
Message: "Internal error fetching user.",
230+
Detail: err.Error(),
231+
})
232+
return
233+
}
234+
235+
httpapi.Write(rw, http.StatusOK,
236+
convertWorkspaceBuild(findUser(workspace.OwnerID, users), findUser(workspaceBuild.InitiatorID, users),
237+
workspace, workspaceBuild, job))
238+
}
239+
163240
func (api *API) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) {
164241
workspace := httpmw.WorkspaceParam(r)
165242
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.

coderd/workspacebuilds_test.go

+90
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package coderd_test
22

33
import (
44
"context"
5+
"fmt"
56
"net/http"
7+
"strconv"
68
"testing"
79
"time"
810

@@ -28,6 +30,94 @@ func TestWorkspaceBuild(t *testing.T) {
2830
require.NoError(t, err)
2931
}
3032

33+
func TestWorkspaceBuildByBuildNumber(t *testing.T) {
34+
t.Parallel()
35+
t.Run("Successful", func(t *testing.T) {
36+
t.Parallel()
37+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
38+
first := coderdtest.CreateFirstUser(t, client)
39+
user, err := client.User(context.Background(), codersdk.Me)
40+
require.NoError(t, err, "fetch me")
41+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
42+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
43+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
44+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
45+
_, err = client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
46+
context.Background(),
47+
user.Username,
48+
workspace.Name,
49+
strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
50+
)
51+
require.NoError(t, err)
52+
})
53+
54+
t.Run("BuildNumberNotInt", func(t *testing.T) {
55+
t.Parallel()
56+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
57+
first := coderdtest.CreateFirstUser(t, client)
58+
user, err := client.User(context.Background(), codersdk.Me)
59+
require.NoError(t, err, "fetch me")
60+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
61+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
62+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
63+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
64+
_, err = client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
65+
context.Background(),
66+
user.Username,
67+
workspace.Name,
68+
"buildNumber",
69+
)
70+
var apiError *codersdk.Error
71+
require.ErrorAs(t, err, &apiError)
72+
require.Equal(t, http.StatusBadRequest, apiError.StatusCode())
73+
require.ErrorContains(t, apiError, "Failed to parse build number as integer.")
74+
})
75+
76+
t.Run("WorkspaceNotFound", func(t *testing.T) {
77+
t.Parallel()
78+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
79+
first := coderdtest.CreateFirstUser(t, client)
80+
user, err := client.User(context.Background(), codersdk.Me)
81+
require.NoError(t, err, "fetch me")
82+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
83+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
84+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
85+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
86+
_, err = client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
87+
context.Background(),
88+
user.Username,
89+
"workspaceName",
90+
strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
91+
)
92+
var apiError *codersdk.Error
93+
require.ErrorAs(t, err, &apiError)
94+
require.Equal(t, http.StatusNotFound, apiError.StatusCode())
95+
require.ErrorContains(t, apiError, "Workspace \"workspaceName\" does not exist.")
96+
})
97+
98+
t.Run("WorkspaceBuildNotFound", func(t *testing.T) {
99+
t.Parallel()
100+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
101+
first := coderdtest.CreateFirstUser(t, client)
102+
user, err := client.User(context.Background(), codersdk.Me)
103+
require.NoError(t, err, "fetch me")
104+
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
105+
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
106+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
107+
workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID)
108+
_, err = client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
109+
context.Background(),
110+
user.Username,
111+
workspace.Name,
112+
"200",
113+
)
114+
var apiError *codersdk.Error
115+
require.ErrorAs(t, err, &apiError)
116+
require.Equal(t, http.StatusNotFound, apiError.StatusCode())
117+
require.ErrorContains(t, apiError, fmt.Sprintf("Workspace %q Build 200 does not exist.", workspace.Name))
118+
})
119+
}
120+
31121
func TestWorkspaceBuilds(t *testing.T) {
32122
t.Parallel()
33123
t.Run("Single", func(t *testing.T) {

codersdk/workspacebuilds.go

+13
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,16 @@ func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]by
103103
}
104104
return io.ReadAll(res.Body)
105105
}
106+
107+
func (c *Client) WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx context.Context, username string, workspaceName string, buildNumber string) (WorkspaceBuild, error) {
108+
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace/%s/builds/%s", username, workspaceName, buildNumber), nil)
109+
if err != nil {
110+
return WorkspaceBuild{}, err
111+
}
112+
defer res.Body.Close()
113+
if res.StatusCode != http.StatusOK {
114+
return WorkspaceBuild{}, readBodyAsError(res)
115+
}
116+
var workspaceBuild WorkspaceBuild
117+
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
118+
}

site/src/AppRouter.tsx

+9-9
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,6 @@ export const AppRouter: FC = () => (
113113
<Route path="ssh-keys" element={<SSHKeysPage />} />
114114
</Route>
115115

116-
<Route
117-
path="builds/:buildId"
118-
element={
119-
<AuthAndFrame>
120-
<WorkspaceBuildPage />
121-
</AuthAndFrame>
122-
}
123-
/>
124-
125116
<Route path="/@:username">
126117
<Route path=":workspace">
127118
<Route
@@ -160,6 +151,15 @@ export const AppRouter: FC = () => (
160151
}
161152
/>
162153
</Route>
154+
155+
<Route
156+
path="builds/:buildNumber"
157+
element={
158+
<AuthAndFrame>
159+
<WorkspaceBuildPage />
160+
</AuthAndFrame>
161+
}
162+
/>
163163
</Route>
164164
</Route>
165165

site/src/api/api.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,14 @@ export const getWorkspaceBuilds = async (workspaceId: string): Promise<TypesGen.
268268
return response.data
269269
}
270270

271-
export const getWorkspaceBuild = async (workspaceId: string): Promise<TypesGen.WorkspaceBuild> => {
272-
const response = await axios.get<TypesGen.WorkspaceBuild>(`/api/v2/workspacebuilds/${workspaceId}`)
271+
export const getWorkspaceBuildByNumber = async (
272+
username = "me",
273+
workspaceName: string,
274+
buildNumber: string,
275+
): Promise<TypesGen.WorkspaceBuild> => {
276+
const response = await axios.get<TypesGen.WorkspaceBuild>(
277+
`/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`,
278+
)
273279
return response.data
274280
}
275281

0 commit comments

Comments
 (0)