diff --git a/cli/server.go b/cli/server.go index f19c0df86fd1b..7bf7f58a77f16 100644 --- a/cli/server.go +++ b/cli/server.go @@ -61,7 +61,6 @@ import ( "github.com/coder/serpent" "github.com/coder/wgtunnel/tunnelsdk" - "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" @@ -754,25 +753,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } - fetcher := &cryptokeys.DBFetcher{ - DB: options.Database, - } - - resumeKeycache, err := cryptokeys.NewSigningCache(ctx, - logger, - fetcher, - codersdk.CryptoKeyFeatureTailnetResume, - ) - if err != nil { - logger.Critical(ctx, "failed to properly instantiate tailnet resume signing cache", slog.Error(err)) - } - - options.CoordinatorResumeTokenProvider = tailnet.NewResumeTokenKeyProvider( - resumeKeycache, - quartz.NewReal(), - tailnet.DefaultResumeTokenExpiry, - ) - options.RuntimeConfig = runtimeconfig.NewManager() // This should be output before the logs start streaming. diff --git a/coderd/coderd.go b/coderd/coderd.go index 70101b7020890..57edec1568326 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -467,7 +467,7 @@ func New(options *Options) *API { codersdk.CryptoKeyFeatureOIDCConvert, ) if err != nil { - options.Logger.Critical(ctx, "failed to properly instantiate oidc convert signing cache", slog.Error(err)) + options.Logger.Fatal(ctx, "failed to properly instantiate oidc convert signing cache", slog.Error(err)) } } @@ -478,7 +478,7 @@ func New(options *Options) *API { codersdk.CryptoKeyFeatureWorkspaceAppsToken, ) if err != nil { - options.Logger.Critical(ctx, "failed to properly instantiate app signing key cache", slog.Error(err)) + options.Logger.Fatal(ctx, "failed to properly instantiate app signing key cache", slog.Error(err)) } } @@ -489,10 +489,30 @@ func New(options *Options) *API { codersdk.CryptoKeyFeatureWorkspaceAppsAPIKey, ) if err != nil { - options.Logger.Critical(ctx, "failed to properly instantiate app encryption key cache", slog.Error(err)) + options.Logger.Fatal(ctx, "failed to properly instantiate app encryption key cache", slog.Error(err)) } } + if options.CoordinatorResumeTokenProvider == nil { + fetcher := &cryptokeys.DBFetcher{ + DB: options.Database, + } + + resumeKeycache, err := cryptokeys.NewSigningCache(ctx, + options.Logger, + fetcher, + codersdk.CryptoKeyFeatureTailnetResume, + ) + if err != nil { + options.Logger.Fatal(ctx, "failed to properly instantiate tailnet resume signing cache", slog.Error(err)) + } + options.CoordinatorResumeTokenProvider = tailnet.NewResumeTokenKeyProvider( + resumeKeycache, + options.Clock, + tailnet.DefaultResumeTokenExpiry, + ) + } + // Start a background process that rotates keys. We intentionally start this after the caches // are created to force initial requests for a key to populate the caches. This helps catch // bugs that may only occur when a key isn't precached in tests and the latency cost is minimal. diff --git a/coderd/files.go b/coderd/files.go index bf1885da1eee9..f82d1aa926c22 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -25,8 +25,9 @@ import ( ) const ( - tarMimeType = "application/x-tar" - zipMimeType = "application/zip" + tarMimeType = "application/x-tar" + zipMimeType = "application/zip" + windowsZipMimeType = "application/x-zip-compressed" HTTPFileMaxBytes = 10 * (10 << 20) ) @@ -48,7 +49,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") switch contentType { - case tarMimeType, zipMimeType: + case tarMimeType, zipMimeType, windowsZipMimeType: default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Unsupported content type header %q.", contentType), @@ -66,7 +67,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { return } - if contentType == zipMimeType { + if contentType == zipMimeType || contentType == windowsZipMimeType { zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/coderd/files_test.go b/coderd/files_test.go index f2dd788e3a6dd..974db6b18fc69 100644 --- a/coderd/files_test.go +++ b/coderd/files_test.go @@ -43,6 +43,18 @@ func TestPostFiles(t *testing.T) { require.NoError(t, err) }) + t.Run("InsertWindowsZip", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.Upload(ctx, "application/x-zip-compressed", bytes.NewReader(archivetest.TestZipFileBytes())) + require.NoError(t, err) + }) + t.Run("InsertAlreadyExists", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index df832b810e696..3db5d7c20a4bf 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -15,6 +15,7 @@ import ( "nhooyr.io/websocket" "cdr.dev/slog" + "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -312,6 +313,7 @@ type logFollower struct { r *http.Request rw http.ResponseWriter conn *websocket.Conn + enc *wsjson.Encoder[codersdk.ProvisionerJobLog] jobID uuid.UUID after int64 @@ -391,6 +393,7 @@ func (f *logFollower) follow() { } defer f.conn.Close(websocket.StatusNormalClosure, "done") go httpapi.Heartbeat(f.ctx, f.conn) + f.enc = wsjson.NewEncoder[codersdk.ProvisionerJobLog](f.conn, websocket.MessageText) // query for logs once right away, so we can get historical data from before // subscription @@ -488,11 +491,7 @@ func (f *logFollower) query() error { return xerrors.Errorf("error fetching logs: %w", err) } for _, log := range logs { - logB, err := json.Marshal(convertProvisionerJobLog(log)) - if err != nil { - return xerrors.Errorf("error marshaling log: %w", err) - } - err = f.conn.Write(f.ctx, websocket.MessageText, logB) + err := f.enc.Encode(convertProvisionerJobLog(log)) if err != nil { return xerrors.Errorf("error writing to websocket: %w", err) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index a181697f27279..449106de4d22a 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -37,6 +37,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/proto" ) @@ -404,11 +405,9 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { } go httpapi.Heartbeat(ctx, conn) - ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) - defer wsNetConn.Close() // Also closes conn. + encoder := wsjson.NewEncoder[[]codersdk.WorkspaceAgentLog](conn, websocket.MessageText) + defer encoder.Close(websocket.StatusNormalClosure) - // The Go stdlib JSON encoder appends a newline character after message write. - encoder := json.NewEncoder(wsNetConn) err = encoder.Encode(convertWorkspaceAgentLogs(logs)) if err != nil { return @@ -741,16 +740,8 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { }) return } - ctx, nconn := codersdk.WebsocketNetConn(ctx, ws, websocket.MessageBinary) - defer nconn.Close() - - // Slurp all packets from the connection into io.Discard so pongs get sent - // by the websocket package. We don't do any reads ourselves so this is - // necessary. - go func() { - _, _ = io.Copy(io.Discard, nconn) - _ = nconn.Close() - }() + encoder := wsjson.NewEncoder[*tailcfg.DERPMap](ws, websocket.MessageBinary) + defer encoder.Close(websocket.StatusGoingAway) go func(ctx context.Context) { // TODO(mafredri): Is this too frequent? Use separate ping disconnect timeout? @@ -768,7 +759,7 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { err := ws.Ping(ctx) cancel() if err != nil { - _ = nconn.Close() + _ = ws.Close(websocket.StatusGoingAway, "ping failed") return } } @@ -781,9 +772,8 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { for { derpMap := api.DERPMap() if lastDERPMap == nil || !tailnet.CompareDERPMaps(lastDERPMap, derpMap) { - err := json.NewEncoder(nconn).Encode(derpMap) + err := encoder.Encode(derpMap) if err != nil { - _ = nconn.Close() return } lastDERPMap = derpMap diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 7ba10539b671c..b3eb601914145 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/codersdk/drpc" + "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionerd/runner" ) @@ -145,36 +146,8 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after } return nil, nil, ReadBodyAsError(res) } - logs := make(chan ProvisionerJobLog) - closed := make(chan struct{}) - go func() { - defer close(closed) - defer close(logs) - defer conn.Close(websocket.StatusGoingAway, "") - var log ProvisionerJobLog - for { - msgType, msg, err := conn.Read(ctx) - if err != nil { - return - } - if msgType != websocket.MessageText { - return - } - err = json.Unmarshal(msg, &log) - if err != nil { - return - } - select { - case <-ctx.Done(): - return - case logs <- log: - } - } - }() - return logs, closeFunc(func() error { - <-closed - return nil - }), nil + d := wsjson.NewDecoder[ProvisionerJobLog](conn, websocket.MessageText, c.logger) + return d.Chan(), d, nil } // ServeProvisionerDaemonRequest are the parameters to call ServeProvisionerDaemon with diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index eeb335b130cdd..b4aec16a83190 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -15,6 +15,7 @@ import ( "nhooyr.io/websocket" "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk/wsjson" ) type WorkspaceAgentStatus string @@ -454,30 +455,6 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, } return nil, nil, ReadBodyAsError(res) } - logChunks := make(chan []WorkspaceAgentLog, 1) - closed := make(chan struct{}) - ctx, wsNetConn := WebsocketNetConn(ctx, conn, websocket.MessageText) - decoder := json.NewDecoder(wsNetConn) - go func() { - defer close(closed) - defer close(logChunks) - defer conn.Close(websocket.StatusGoingAway, "") - for { - var logs []WorkspaceAgentLog - err = decoder.Decode(&logs) - if err != nil { - return - } - select { - case <-ctx.Done(): - return - case logChunks <- logs: - } - } - }() - return logChunks, closeFunc(func() error { - _ = wsNetConn.Close() - <-closed - return nil - }), nil + d := wsjson.NewDecoder[[]WorkspaceAgentLog](conn, websocket.MessageText, c.logger) + return d.Chan(), d, nil } diff --git a/codersdk/wsjson/decoder.go b/codersdk/wsjson/decoder.go new file mode 100644 index 0000000000000..4cc7ff380a73a --- /dev/null +++ b/codersdk/wsjson/decoder.go @@ -0,0 +1,75 @@ +package wsjson + +import ( + "context" + "encoding/json" + "sync/atomic" + + "nhooyr.io/websocket" + + "cdr.dev/slog" +) + +type Decoder[T any] struct { + conn *websocket.Conn + typ websocket.MessageType + ctx context.Context + cancel context.CancelFunc + chanCalled atomic.Bool + logger slog.Logger +} + +// Chan starts the decoder reading from the websocket and returns a channel for reading the +// resulting values. The chan T is closed if the underlying websocket is closed, or we encounter an +// error. We also close the underlying websocket if we encounter an error reading or decoding. +func (d *Decoder[T]) Chan() <-chan T { + if !d.chanCalled.CompareAndSwap(false, true) { + panic("chan called more than once") + } + values := make(chan T, 1) + go func() { + defer close(values) + defer d.conn.Close(websocket.StatusGoingAway, "") + for { + // we don't use d.ctx here because it only gets canceled after closing the connection + // and a "connection closed" type error is more clear than context canceled. + typ, b, err := d.conn.Read(context.Background()) + if err != nil { + // might be benign like EOF, so just log at debug + d.logger.Debug(d.ctx, "error reading from websocket", slog.Error(err)) + return + } + if typ != d.typ { + d.logger.Error(d.ctx, "websocket type mismatch while decoding") + return + } + var value T + err = json.Unmarshal(b, &value) + if err != nil { + d.logger.Error(d.ctx, "error unmarshalling", slog.Error(err)) + return + } + select { + case values <- value: + // OK + case <-d.ctx.Done(): + return + } + } + }() + return values +} + +// nolint: revive // complains that Encoder has the same function name +func (d *Decoder[T]) Close() error { + err := d.conn.Close(websocket.StatusNormalClosure, "") + d.cancel() + return err +} + +// NewDecoder creates a JSON-over-websocket decoder for type T, which must be deserializable from +// JSON. +func NewDecoder[T any](conn *websocket.Conn, typ websocket.MessageType, logger slog.Logger) *Decoder[T] { + ctx, cancel := context.WithCancel(context.Background()) + return &Decoder[T]{conn: conn, ctx: ctx, cancel: cancel, typ: typ, logger: logger} +} diff --git a/codersdk/wsjson/encoder.go b/codersdk/wsjson/encoder.go new file mode 100644 index 0000000000000..4cde05984e690 --- /dev/null +++ b/codersdk/wsjson/encoder.go @@ -0,0 +1,42 @@ +package wsjson + +import ( + "context" + "encoding/json" + + "golang.org/x/xerrors" + "nhooyr.io/websocket" +) + +type Encoder[T any] struct { + conn *websocket.Conn + typ websocket.MessageType +} + +func (e *Encoder[T]) Encode(v T) error { + w, err := e.conn.Writer(context.Background(), e.typ) + if err != nil { + return xerrors.Errorf("get websocket writer: %w", err) + } + defer w.Close() + j := json.NewEncoder(w) + err = j.Encode(v) + if err != nil { + return xerrors.Errorf("encode json: %w", err) + } + return nil +} + +func (e *Encoder[T]) Close(c websocket.StatusCode) error { + return e.conn.Close(c, "") +} + +// NewEncoder creates a JSON-over websocket encoder for the type T, which must be JSON-serializable. +// You may then call Encode() to send objects over the websocket. Creating an Encoder closes the +// websocket for reading, turning it into a unidirectional write stream of JSON-encoded objects. +func NewEncoder[T any](conn *websocket.Conn, typ websocket.MessageType) *Encoder[T] { + // Here we close the websocket for reading, so that the websocket library will handle pings and + // close frames. + _ = conn.CloseRead(context.Background()) + return &Encoder[T]{conn: conn, typ: typ} +} diff --git a/docs/images/user-guides/amazon-dcv-windows-demo.png b/docs/images/user-guides/amazon-dcv-windows-demo.png new file mode 100644 index 0000000000000..5dd2deef076f6 Binary files /dev/null and b/docs/images/user-guides/amazon-dcv-windows-demo.png differ diff --git a/docs/user-guides/workspace-access/remote-desktops.md b/docs/user-guides/workspace-access/remote-desktops.md index 65511bd67f1e8..f95d7717983ed 100644 --- a/docs/user-guides/workspace-access/remote-desktops.md +++ b/docs/user-guides/workspace-access/remote-desktops.md @@ -58,3 +58,12 @@ requires just a few lines of Terraform in your template, see the documentation on our registry for setup. ![Web RDP Module in a Workspace](../../images/user-guides/web-rdp-demo.png) + +## Amazon DCV Windows + +Our [Amazon DCV Windows](https://registry.coder.com/modules/amazon-dcv-windows) +module adds a one-click button to open an Amazon DCV session in the browser. +This requires just a few lines of Terraform in your template, see the +documentation on our registry for setup. + +![Amazon DCV Windows Module in a Workspace](../../images/user-guides/amazon-dcv-windows-demo.png) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b79fea12a0c31..335cc5582367d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1868,7 +1868,7 @@ class ApiMethods { uploadFile = async (file: File): Promise => { const response = await this.axios.post("/api/v2/files", file, { - headers: { "Content-Type": "application/x-tar" }, + headers: { "Content-Type": file.type }, }); return response.data; diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 916184c2d4bbc..a0a5740420f40 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -226,6 +226,28 @@ test("Patch request is not send when there are no changes", async () => { expect(patchTemplateVersion).toBeCalledTimes(0); }); +test("The file is uploaded with the correct content type", async () => { + const user = userEvent.setup(); + renderTemplateEditorPage(); + const topbar = await screen.findByTestId("topbar"); + + const newTemplateVersion = { + ...MockTemplateVersion, + id: "new-version-id", + name: "new-version", + }; + + await typeOnEditor("new content", user); + await buildTemplateVersion(newTemplateVersion, user, topbar); + + expect(API.uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + name: "template.tar", + type: "application/x-tar", + }), + ); +}); + describe.each([ { testName: "Do not ask when template version has no errors", diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index cf74c0b099ecf..b3090eb6d3f47 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -329,7 +329,7 @@ const generateVersionFiles = async ( tar.addFolder(fullPath, baseFileInfo); }); const blob = (await tar.write()) as Blob; - return new File([blob], "template.tar"); + return new File([blob], "template.tar", { type: "application/x-tar" }); }; const publishVersion = async (options: { diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index b99585ab34b3f..bb1754ff8d9f7 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -25,6 +25,7 @@ "database.svg", "datagrip.svg", "dataspell.svg", + "dcv.svg", "debian.svg", "desktop.svg", "discord.svg", diff --git a/site/static/icon/dcv.svg b/site/static/icon/dcv.svg new file mode 100644 index 0000000000000..6a73c7b911394 --- /dev/null +++ b/site/static/icon/dcv.svg @@ -0,0 +1 @@ + \ No newline at end of file pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy