diff --git a/cli/buildinfo/buildinfo.go b/buildinfo/buildinfo.go similarity index 100% rename from cli/buildinfo/buildinfo.go rename to buildinfo/buildinfo.go diff --git a/cli/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go similarity index 94% rename from cli/buildinfo/buildinfo_test.go rename to buildinfo/buildinfo_test.go index 15733afe2c2e0..9a92927a5b420 100644 --- a/cli/buildinfo/buildinfo_test.go +++ b/buildinfo/buildinfo_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/mod/semver" - "github.com/coder/coder/cli/buildinfo" + "github.com/coder/coder/buildinfo" ) func TestBuildInfo(t *testing.T) { diff --git a/cli/root.go b/cli/root.go index 9f44fefbb5686..23289edec8b54 100644 --- a/cli/root.go +++ b/cli/root.go @@ -11,7 +11,7 @@ import ( "github.com/mattn/go-isatty" "github.com/spf13/cobra" - "github.com/coder/coder/cli/buildinfo" + "github.com/coder/coder/buildinfo" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" "github.com/coder/coder/codersdk" diff --git a/coderd/coderd.go b/coderd/coderd.go index ebf8b810ea560..fc3ac5a9f33e2 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -7,16 +7,19 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" "google.golang.org/api/idtoken" chitrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-chi/chi.v5" "cdr.dev/slog" + "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" "github.com/coder/coder/site" ) @@ -59,6 +62,15 @@ func New(options *Options) (http.Handler, func()) { Message: "👋", }) }) + r.Route("/buildinfo", func(r chi.Router) { + r.Get("/", func(rw http.ResponseWriter, r *http.Request) { + render.Status(r, http.StatusOK) + render.JSON(rw, r, codersdk.BuildInfoResponse{ + ExternalURL: buildinfo.ExternalURL(), + Version: buildinfo.Version(), + }) + }) + }) r.Route("/files", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index ef360326b1d8e..73d3c3d308def 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -1,11 +1,26 @@ package coderd_test import ( + "context" "testing" "go.uber.org/goleak" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/coderdtest" ) func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } + +func TestBuildInfo(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + buildInfo, err := client.BuildInfo(context.Background()) + require.NoError(t, err) + require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL") + require.Equal(t, buildinfo.Version(), buildInfo.Version, "version") +} diff --git a/codersdk/buildinfo.go b/codersdk/buildinfo.go new file mode 100644 index 0000000000000..a3aecc1ffdfed --- /dev/null +++ b/codersdk/buildinfo.go @@ -0,0 +1,33 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" +) + +// BuildInfoResponse contains build information for this instance of Coder. +type BuildInfoResponse struct { + // ExternalURL is a URL referencing the current Coder version. For production + // builds, this will link directly to a release. For development builds, this + // will link to a commit. + ExternalURL string `json:"external_url"` + // Version returns the semantic version of the build. + Version string `json:"version"` +} + +// BuildInfo returns build information for this instance of Coder. +func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) { + res, err := c.request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) + if err != nil { + return BuildInfoResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return BuildInfoResponse{}, readBodyAsError(res) + } + + var buildInfo BuildInfoResponse + return buildInfo, json.NewDecoder(res.Body).Decode(&buildInfo) +} diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 2c4f0c08cc48d..764e32489f438 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -68,3 +68,8 @@ export const getApiKey = async (): Promise => { const response = await axios.post("/api/v2/users/me/keys") return response.data } + +export const getBuildInfo = async (): Promise => { + const response = await axios.get("/api/v2/buildinfo") + return response.data +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 8c21c271dd9cc..04f5192fd5279 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -1,3 +1,11 @@ +/** + * `BuildInfoResponse` must be kept in sync with the go struct in buildinfo.go. + */ +export interface BuildInfoResponse { + external_url: string + version: string +} + export interface LoginResponse { session_token: string } diff --git a/site/src/components/Page/Footer.test.tsx b/site/src/components/Page/Footer.test.tsx index e61694b93606a..9b8e4898183ff 100644 --- a/site/src/components/Page/Footer.test.tsx +++ b/site/src/components/Page/Footer.test.tsx @@ -1,7 +1,7 @@ import { screen } from "@testing-library/react" import React from "react" -import { render } from "../../test_helpers" -import { Footer } from "./Footer" +import { MockBuildInfo, render } from "../../test_helpers" +import { Footer, Language } from "./Footer" describe("Footer", () => { it("renders content", async () => { @@ -10,5 +10,6 @@ describe("Footer", () => { // Then await screen.findByText("Copyright", { exact: false }) + await screen.findByText(Language.buildInfoText(MockBuildInfo)) }) }) diff --git a/site/src/components/Page/Footer.tsx b/site/src/components/Page/Footer.tsx index b0f1e4b225439..0ac758f470591 100644 --- a/site/src/components/Page/Footer.tsx +++ b/site/src/components/Page/Footer.tsx @@ -1,9 +1,21 @@ +import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import React from "react" +import { useActor } from "@xstate/react" +import React, { useContext } from "react" +import { BuildInfoResponse } from "../../api/types" +import { XServiceContext } from "../../xServices/StateContext" + +export const Language = { + buildInfoText: (buildInfo: BuildInfoResponse): string => { + return `Coder ${buildInfo.version}` + }, +} export const Footer: React.FC = ({ children }) => { const styles = useFooterStyles() + const xServices = useContext(XServiceContext) + const [buildInfoState] = useActor(xServices.buildInfoXService) return (
@@ -13,11 +25,13 @@ export const Footer: React.FC = ({ children }) => { {`Copyright \u00a9 ${new Date().getFullYear()} Coder Technologies, Inc. All rights reserved.`}
-
- - v2 0.0.0-prototype - -
+ {buildInfoState.context.buildInfo && ( +
+ + {Language.buildInfoText(buildInfoState.context.buildInfo)} + +
+ )} ) } @@ -29,11 +43,9 @@ const useFooterStyles = makeStyles((theme) => ({ flex: "0", }, copyRight: { - backgroundColor: theme.palette.background.default, margin: theme.spacing(0.25), }, - version: { - backgroundColor: theme.palette.background.default, + buildInfo: { margin: theme.spacing(0.25), }, })) diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index 3b5b887e262ba..b3f6614e63ba3 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -1,9 +1,22 @@ -import { Organization, Provisioner, Template, UserAgent, UserResponse, Workspace } from "../api/types" +import { + BuildInfoResponse, + Organization, + Provisioner, + Template, + UserAgent, + UserResponse, + Workspace, +} from "../api/types" export const MockSessionToken = { session_token: "my-session-token" } export const MockAPIKey = { key: "my-api-key" } +export const MockBuildInfo: BuildInfoResponse = { + external_url: "file:///mock-url", + version: "v99.999.9999+c9cdf14", +} + export const MockUser: UserResponse = { id: "test-user", username: "TestUser", diff --git a/site/src/test_helpers/handlers.ts b/site/src/test_helpers/handlers.ts index cc391cb8684c8..a7de1446ba9d6 100644 --- a/site/src/test_helpers/handlers.ts +++ b/site/src/test_helpers/handlers.ts @@ -2,6 +2,11 @@ import { rest } from "msw" import * as M from "./entities" export const handlers = [ + // build info + rest.get("/api/v2/buildinfo", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockBuildInfo)) + }), + // organizations rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockOrganization)) diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index 26ba57465504a..d043f463c27fb 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -1,9 +1,11 @@ import { useInterpret } from "@xstate/react" import React, { createContext } from "react" import { ActorRefFrom } from "xstate" +import { buildInfoMachine } from "./buildInfo/buildInfoXService" import { userMachine } from "./user/userXService" interface XServiceContextType { + buildInfoXService: ActorRefFrom userXService: ActorRefFrom } @@ -18,7 +20,14 @@ interface XServiceContextType { export const XServiceContext = createContext({} as XServiceContextType) export const XServiceProvider: React.FC = ({ children }) => { - const userXService = useInterpret(userMachine) - - return {children} + return ( + + {children} + + ) } diff --git a/site/src/xServices/buildInfo/buildInfoXService.ts b/site/src/xServices/buildInfo/buildInfoXService.ts new file mode 100644 index 0000000000000..2c428b2ec2460 --- /dev/null +++ b/site/src/xServices/buildInfo/buildInfoXService.ts @@ -0,0 +1,74 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api" +import * as Types from "../../api/types" + +export interface BuildInfoContext { + getBuildInfoError?: Error | unknown + buildInfo?: Types.BuildInfoResponse +} + +export const buildInfoMachine = createMachine( + { + tsTypes: {} as import("./buildInfoXService.typegen").Typegen0, + schema: { + context: {} as BuildInfoContext, + services: {} as { + getBuildInfo: { + data: Types.BuildInfoResponse + } + }, + }, + context: { + buildInfo: undefined, + }, + id: "buildInfoState", + initial: "gettingBuildInfo", + states: { + gettingBuildInfo: { + invoke: { + src: "getBuildInfo", + id: "getBuildInfo", + onDone: [ + { + actions: ["assignBuildInfo", "clearGetBuildInfoError"], + target: "#buildInfoState.success", + }, + ], + onError: [ + { + actions: ["assignGetBuildInfoError", "clearBuildInfo"], + target: "#buildInfoState.failure", + }, + ], + }, + }, + success: { + type: "final", + }, + failure: { + type: "final", + }, + }, + }, + { + services: { + getBuildInfo: API.getBuildInfo, + }, + actions: { + assignBuildInfo: assign({ + buildInfo: (_, event) => event.data, + }), + clearBuildInfo: assign((context: BuildInfoContext) => ({ + ...context, + buildInfo: undefined, + })), + assignGetBuildInfoError: assign({ + getBuildInfoError: (_, event) => event.data, + }), + clearGetBuildInfoError: assign((context: BuildInfoContext) => ({ + ...context, + getBuildInfoError: undefined, + })), + }, + }, +)