Skip to content

Commit

Permalink
feat: Add provisionerdaemon to coderd (#141)
Browse files Browse the repository at this point in the history
* feat: Add history middleware parameters

These will be used for streaming logs, checking status,
and other operations related to workspace and project
history.

* refactor: Move all HTTP routes to top-level struct

Nesting all structs behind their respective structures
is leaky, and promotes naming conflicts between handlers.

Our HTTP routes cannot have conflicts, so neither should
function naming.

* Add provisioner daemon routes

* Add periodic updates

* Skip pubsub if short

* Return jobs with WorkspaceHistory

* Add endpoints for extracting singular history

* The full end-to-end operation works

* fix: Disable compression for websocket dRPC transport (#145)

There is a race condition in the interop between the websocket and `dRPC`: https://github.com/coder/coder/runs/5038545709?check_suite_focus=true#step:7:117 - it seems both the websocket and dRPC feel like they own the `byte[]` being sent between them. This can lead to data races, in which both `dRPC` and the websocket are writing.

This is just tracking some experimentation to fix that race condition

## Run results: ##
- Run 1: peer test failure
- Run 2: peer test failure
- Run 3: `TestWorkspaceHistory/CreateHistory`  - https://github.com/coder/coder/runs/5040858460?check_suite_focus=true#step:8:45
```
status code 412: The provided project history is running. Wait for it to complete importing!`
```
- Run 4: `TestWorkspaceHistory/CreateHistory` - https://github.com/coder/coder/runs/5040957999?check_suite_focus=true#step:7:176
```
    workspacehistory_test.go:122: 
        	Error Trace:	workspacehistory_test.go:122
        	Error:      	Condition never satisfied
        	Test:       	TestWorkspaceHistory/CreateHistory
```
- Run 5: peer failure
- Run 6: Pass ✅ 
- Run 7: Peer failure

## Open Questions: ##

### Is `dRPC` or `websocket` at fault for the data race?

It looks like this condition is specifically happening when `dRPC` decides to [`SendError`]). This constructs a new byte payload from [`MarshalError`](https://github.com/storj/drpc/blob/f6e369438f636b47ee788095d3fc13062ffbd019/drpcwire/error.go#L15) - so `dRPC` has created this buffer and owns it.

From `dRPC`'s perspective, the callstack looks like this:
- [`sendPacket`](https://github.com/storj/drpc/blob/f6e369438f636b47ee788095d3fc13062ffbd019/drpcstream/stream.go#L253)
  - [`writeFrame`](https://github.com/storj/drpc/blob/f6e369438f636b47ee788095d3fc13062ffbd019/drpcwire/writer.go#L65)
    - [`AppendFrame`](https://github.com/storj/drpc/blob/f6e369438f636b47ee788095d3fc13062ffbd019/drpcwire/packet.go#L128)
      - with finally the data race happening here:
```go
// AppendFrame appends a marshaled form of the frame to the provided buffer.
func AppendFrame(buf []byte, fr Frame) []byte {
...
	out := buf
	out = append(out, control).   // <---------
```

This should be fine, since `dPRC` create this buffer, and is taking the byte buffer constructed from `MarshalError` and tacking a bunch of headers on it to create a proper frame.

Once `dRPC` is done writing, it _hangs onto the buffer and resets it here__: https://github.com/storj/drpc/blob/f6e369438f636b47ee788095d3fc13062ffbd019/drpcwire/writer.go#L73

However... the websocket implementation, once it gets the buffer, it runs a `statelessDeflate` [here](https://github.com/nhooyr/websocket/blob/8dee580a7f74cf1713400307b4eee514b927870f/write.go#L180), which compresses the buffer on the fly. This functionality actually [mutates the buffer in place](https://github.com/klauspost/compress/blob/a1a9cfc821f00faf2f5231beaa96244344d50391/flate/stateless.go#L94), which is where get our race.

In the case where the `byte[]` aren't being manipulated anywhere else, this compress-in-place operation would be safe, and that's probably the case for most over-the-wire usages. In this case, though, where we're plumbing `dRPC` -> websocket, they both are manipulating it (`dRPC` is reusing the buffer for the next `write`, and `websocket` is compressing on the fly).

### Why does cloning on `Read` fail?

Get a bunch of errors like:
```
2022/02/02 19:26:10 [WARN] yamux: frame for missing stream: Vsn:0 Type:0 Flags:0 StreamID:0 Length:0
2022/02/02 19:26:25 [ERR] yamux: Failed to read header: unexpected EOF
2022/02/02 19:26:25 [ERR] yamux: Failed to read header: unexpected EOF
2022/02/02 19:26:25 [WARN] yamux: frame for missing stream: Vsn:0 Type:0 Flags:0 StreamID:0 Length:0
```

# UPDATE:

We decided we could disable websocket compression, which would avoid the race because the in-place `deflate` operaton would no longer be run. Trying that out now:

- Run 1: ✅ 
- Run 2: https://github.com/coder/coder/runs/5042645522?check_suite_focus=true#step:8:338
- Run 3: ✅ 
- Run 4: https://github.com/coder/coder/runs/5042988758?check_suite_focus=true#step:7:168
- Run 5: ✅

* fix: Remove race condition with acquiredJobDone channel (#148)

Found another data race while running the tests: https://github.com/coder/coder/runs/5044320845?check_suite_focus=true#step:7:83

__Issue:__ There is a race in the p.acquiredJobDone chan - in particular, there can be a case where we're waiting on the channel to finish (in close) with <-p.acquiredJobDone, but in parallel, an acquireJob could've been started, which would create a new channel for p.acquiredJobDone. There is a similar race in `close(..)`ing the channel, which also came up in test runs.

__Fix:__ Instead of recreating the channel everytime, we can use `sync.WaitGroup` to accomplish the same functionality - a semaphore to make close wait for the current job to wrap up.

* fix: Bump up workspace history timeout (#149)

This is an attempted fix for failures like: https://github.com/coder/coder/runs/5043435263?check_suite_focus=true#step:7:32

Looking at the timing of the test:
```
    t.go:56: 2022-02-02 21:33:21.964 [DEBUG]	(terraform-provisioner)	<provision.go:139>	ran apply
    t.go:56: 2022-02-02 21:33:21.991 [DEBUG]	(provisionerd)	<provisionerd.go:162>	skipping acquire; job is already running
    t.go:56: 2022-02-02 21:33:22.050 [DEBUG]	(provisionerd)	<provisionerd.go:162>	skipping acquire; job is already running
    t.go:56: 2022-02-02 21:33:22.090 [DEBUG]	(provisionerd)	<provisionerd.go:162>	skipping acquire; job is already running
    t.go:56: 2022-02-02 21:33:22.140 [DEBUG]	(provisionerd)	<provisionerd.go:162>	skipping acquire; job is already running
    t.go:56: 2022-02-02 21:33:22.195 [DEBUG]	(provisionerd)	<provisionerd.go:162>	skipping acquire; job is already running
    t.go:56: 2022-02-02 21:33:22.240 [DEBUG]	(provisionerd)	<provisionerd.go:162>	skipping acquire; job is already running
    workspacehistory_test.go:122: 
        	Error Trace:	workspacehistory_test.go:122
        	Error:      	Condition never satisfied
        	Test:       	TestWorkspaceHistory/CreateHistory
```

It  appears that the `terraform apply` job had just finished - with less than a second to spare until our `require.Eventually` completes - but there's still work to be done (ie, collecting the state files). So my suspicion is that terraform might, in some cases, exceed our 5s timeout.

Note that in the setup for this test - there is a similar project history wait that waits for 15s, so I borrowed that here.

In the future - we can look at potentially using a simple echo provider to exercise this in the unit test, in a way that is more reliable in terms of timing. I'll log an issue to track that.

Co-authored-by: Bryan <bryan@coder.com>
  • Loading branch information
kylecarbs and bryphe-coder committed Feb 3, 2022
1 parent 35dd208 commit e75bde4
Show file tree
Hide file tree
Showing 36 changed files with 1,518 additions and 177 deletions.
1 change: 0 additions & 1 deletion .github/workflows/coder.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ jobs:
- run: go install gotest.tools/gotestsum@latest

- uses: hashicorp/setup-terraform@v1
if: runner.os == 'Linux'
with:
terraform_version: 1.1.2
terraform_wrapper: false
Expand Down
29 changes: 28 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,32 @@
}
]
},
"cSpell.words": ["coderd", "coderdtest", "codersdk", "httpmw", "oneof", "stretchr", "xerrors"]
"cSpell.words": [
"coderd",
"coderdtest",
"codersdk",
"drpc",
"drpcconn",
"drpcmux",
"drpcserver",
"goleak",
"hashicorp",
"httpmw",
"moby",
"nhooyr",
"nolint",
"nosec",
"oneof",
"protobuf",
"provisionerd",
"provisionersdk",
"retrier",
"sdkproto",
"stretchr",
"tfexec",
"tfstate",
"unconvert",
"xerrors",
"yamux"
]
}
2 changes: 2 additions & 0 deletions coderd/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/coderd"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
)

Expand All @@ -24,6 +25,7 @@ func Root() *cobra.Command {
handler := coderd.New(&coderd.Options{
Logger: slog.Make(sloghuman.Sink(os.Stderr)),
Database: databasefake.New(),
Pubsub: database.NewPubsubInMemory(),
})

listener, err := net.Listen("tcp", address)
Expand Down
14 changes: 13 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func New(options *Options) http.Handler {
r.Route("/history", func(r chi.Router) {
r.Get("/", api.projectHistoryByOrganization)
r.Post("/", api.postProjectHistoryByOrganization)
r.Route("/{projecthistory}", func(r chi.Router) {
r.Use(httpmw.ExtractProjectHistoryParam(api.Database))
r.Get("/", api.projectHistoryByOrganizationAndName)
})
})
})
})
Expand All @@ -84,11 +88,19 @@ func New(options *Options) http.Handler {
r.Route("/history", func(r chi.Router) {
r.Post("/", api.postWorkspaceHistoryByUser)
r.Get("/", api.workspaceHistoryByUser)
r.Get("/latest", api.latestWorkspaceHistoryByUser)
r.Route("/{workspacehistory}", func(r chi.Router) {
r.Use(httpmw.ExtractWorkspaceHistoryParam(options.Database))
r.Get("/", api.workspaceHistoryByName)
})
})
})
})
})

r.Route("/provisioners/daemons", func(r chi.Router) {
r.Get("/", api.provisionerDaemons)
r.Get("/serve", api.provisionerDaemonsServe)
})
})
r.NotFound(site.Handler().ServeHTTP)
return r
Expand Down
49 changes: 49 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@ package coderdtest
import (
"context"
"database/sql"
"io"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"

"github.com/stretchr/testify/require"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
"github.com/coder/coder/database/postgres"
"github.com/coder/coder/provisioner/terraform"
"github.com/coder/coder/provisionerd"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/provisionersdk/proto"
)

// Server represents a test instance of coderd.
Expand Down Expand Up @@ -57,11 +64,46 @@ func (s *Server) RandomInitialUser(t *testing.T) coderd.CreateInitialUserRequest
return req
}

// AddProvisionerd launches a new provisionerd instance!
func (s *Server) AddProvisionerd(t *testing.T) io.Closer {
tfClient, tfServer := provisionersdk.TransportPipe()
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(func() {
_ = tfClient.Close()
_ = tfServer.Close()
cancelFunc()
})
go func() {
err := terraform.Serve(ctx, &terraform.ServeOptions{
ServeOptions: &provisionersdk.ServeOptions{
Listener: tfServer,
},
Logger: slogtest.Make(t, nil).Named("terraform-provisioner").Leveled(slog.LevelDebug),
})
require.NoError(t, err)
}()

closer := provisionerd.New(s.Client.ProvisionerDaemonClient, &provisionerd.Options{
Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug),
PollInterval: 50 * time.Millisecond,
UpdateInterval: 50 * time.Millisecond,
Provisioners: provisionerd.Provisioners{
string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(tfClient)),
},
WorkDirectory: t.TempDir(),
})
t.Cleanup(func() {
_ = closer.Close()
})
return closer
}

// New constructs a new coderd test instance. This returned Server
// should contain no side-effects.
func New(t *testing.T) Server {
// This can be hotswapped for a live database instance.
db := databasefake.New()
pubsub := database.NewPubsubInMemory()
if os.Getenv("DB") != "" {
connectionURL, close, err := postgres.Open()
require.NoError(t, err)
Expand All @@ -74,11 +116,18 @@ func New(t *testing.T) Server {
err = database.Migrate(sqlDB)
require.NoError(t, err)
db = database.New(sqlDB)

pubsub, err = database.NewPubsub(context.Background(), sqlDB, connectionURL)
require.NoError(t, err)
t.Cleanup(func() {
_ = pubsub.Close()
})
}

handler := coderd.New(&coderd.Options{
Logger: slogtest.Make(t, nil),
Database: db,
Pubsub: pubsub,
})
srv := httptest.NewServer(handler)
serverURL, err := url.Parse(srv.URL)
Expand Down
1 change: 1 addition & 0 deletions coderd/coderdtest/coderdtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ func TestNew(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
_ = server.AddProvisionerd(t)
}
90 changes: 74 additions & 16 deletions coderd/projecthistory.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"archive/tar"
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -12,6 +13,7 @@ import (
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/xerrors"

"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
Expand All @@ -26,6 +28,7 @@ type ProjectHistory struct {
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
StorageMethod database.ProjectStorageMethod `json:"storage_method"`
Import ProvisionerJob `json:"import"`
}

// CreateProjectHistoryRequest enables callers to create a new Project Version.
Expand All @@ -50,12 +53,33 @@ func (api *api) projectHistoryByOrganization(rw http.ResponseWriter, r *http.Req
}
apiHistory := make([]ProjectHistory, 0)
for _, version := range history {
apiHistory = append(apiHistory, convertProjectHistory(version))
job, err := api.Database.GetProvisionerJobByID(r.Context(), version.ImportJobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
apiHistory = append(apiHistory, convertProjectHistory(version, job))
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, apiHistory)
}

// Return a single project history by organization and name.
func (api *api) projectHistoryByOrganizationAndName(rw http.ResponseWriter, r *http.Request) {
projectHistory := httpmw.ProjectHistoryParam(r)
job, err := api.Database.GetProvisionerJobByID(r.Context(), projectHistory.ImportJobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get provisioner job: %s", err),
})
return
}
render.Status(r, http.StatusOK)
render.JSON(rw, r, convertProjectHistory(projectHistory, job))
}

// Creates a new version of the project. An import job is queued to parse
// the storage method provided. Once completed, the import job will specify
// the version as latest.
Expand All @@ -82,37 +106,71 @@ func (api *api) postProjectHistoryByOrganization(rw http.ResponseWriter, r *http
return
}

apiKey := httpmw.APIKey(r)
project := httpmw.ProjectParam(r)
history, err := api.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{
ID: uuid.New(),
ProjectID: project.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Name: namesgenerator.GetRandomName(1),
StorageMethod: createProjectVersion.StorageMethod,
StorageSource: createProjectVersion.StorageSource,
// TODO: Make this do something!
ImportJobID: uuid.New(),

var provisionerJob database.ProvisionerJob
var projectHistory database.ProjectHistory
err := api.Database.InTx(func(db database.Store) error {
projectHistoryID := uuid.New()
input, err := json.Marshal(projectImportJob{
ProjectHistoryID: projectHistoryID,
})
if err != nil {
return xerrors.Errorf("marshal import job: %w", err)
}

provisionerJob, err = db.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
InitiatorID: apiKey.UserID,
Provisioner: project.Provisioner,
Type: database.ProvisionerJobTypeProjectImport,
ProjectID: project.ID,
Input: input,
})
if err != nil {
return xerrors.Errorf("insert provisioner job: %w", err)
}

projectHistory, err = api.Database.InsertProjectHistory(r.Context(), database.InsertProjectHistoryParams{
ID: projectHistoryID,
ProjectID: project.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Name: namesgenerator.GetRandomName(1),
StorageMethod: createProjectVersion.StorageMethod,
StorageSource: createProjectVersion.StorageSource,
ImportJobID: provisionerJob.ID,
})
if err != nil {
return xerrors.Errorf("insert project history: %s", err)
}
return nil
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("insert project history: %s", err),
Message: err.Error(),
})
return
}

// TODO: A job to process the new version should occur here.

render.Status(r, http.StatusCreated)
render.JSON(rw, r, convertProjectHistory(history))
render.JSON(rw, r, convertProjectHistory(projectHistory, provisionerJob))
}

func convertProjectHistory(history database.ProjectHistory) ProjectHistory {
func convertProjectHistory(history database.ProjectHistory, job database.ProvisionerJob) ProjectHistory {
return ProjectHistory{
ID: history.ID,
ProjectID: history.ProjectID,
CreatedAt: history.CreatedAt,
UpdatedAt: history.UpdatedAt,
Name: history.Name,
Import: convertProvisionerJob(job),
}
}

func projectHistoryLogsChannel(projectHistoryID uuid.UUID) string {
return fmt.Sprintf("project-history-logs:%s", projectHistoryID)
}
9 changes: 6 additions & 3 deletions coderd/projecthistory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestProjectHistory(t *testing.T) {
Provisioner: database.ProvisionerTypeTerraform,
})
require.NoError(t, err)
versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name)
versions, err := server.Client.ListProjectHistory(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, versions, 0)
})
Expand All @@ -48,14 +48,17 @@ func TestProjectHistory(t *testing.T) {
require.NoError(t, err)
_, err = writer.Write(make([]byte, 1<<10))
require.NoError(t, err)
_, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{
history, err := server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{
StorageMethod: database.ProjectStorageMethodInlineArchive,
StorageSource: buffer.Bytes(),
})
require.NoError(t, err)
versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name)
versions, err := server.Client.ListProjectHistory(context.Background(), user.Organization, project.Name)
require.NoError(t, err)
require.Len(t, versions, 1)

_, err = server.Client.ProjectHistory(context.Background(), user.Organization, project.Name, history.Name)
require.NoError(t, err)
})

t.Run("CreateHistoryArchiveTooBig", func(t *testing.T) {
Expand Down

0 comments on commit e75bde4

Please sign in to comment.