From ea690169b6f757d7de4e0638a1fb838ddef9bc90 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 18:19:50 +0000 Subject: [PATCH 1/2] feat: Add history middleware parameters These will be used for streaming logs, checking status, and other operations related to workspace and project history. --- database/databasefake/databasefake.go | 1 + database/query.sql | 5 +- database/query.sql.go | 7 +- go.mod | 3 + go.sum | 17 +++ httpmw/projecthistoryparam.go | 60 ++++++++++ httpmw/projecthistoryparam_test.go | 161 ++++++++++++++++++++++++++ httpmw/workspacehistoryparam.go | 60 ++++++++++ httpmw/workspacehistoryparam_test.go | 145 +++++++++++++++++++++++ 9 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 httpmw/projecthistoryparam.go create mode 100644 httpmw/projecthistoryparam_test.go create mode 100644 httpmw/workspacehistoryparam.go create mode 100644 httpmw/workspacehistoryparam_test.go diff --git a/database/databasefake/databasefake.go b/database/databasefake/databasefake.go index 4d94a39573769..19b5b0a0d060e 100644 --- a/database/databasefake/databasefake.go +++ b/database/databasefake/databasefake.go @@ -679,6 +679,7 @@ func (q *fakeQuerier) InsertWorkspaceHistory(_ context.Context, arg database.Ins Transition: arg.Transition, Initiator: arg.Initiator, ProvisionJobID: arg.ProvisionJobID, + ProvisionerState: arg.ProvisionerState, } q.workspaceHistory = append(q.workspaceHistory, workspaceHistory) return workspaceHistory, nil diff --git a/database/query.sql b/database/query.sql index 4c18f5ed10ec7..42f654a4bce9e 100644 --- a/database/query.sql +++ b/database/query.sql @@ -552,10 +552,11 @@ INSERT INTO name, transition, initiator, - provision_job_id + provision_job_id, + provisioner_state ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *; -- name: InsertWorkspaceHistoryLogs :many INSERT INTO diff --git a/database/query.sql.go b/database/query.sql.go index 45507b50c7928..0451cc54580c3 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -1935,10 +1935,11 @@ INSERT INTO name, transition, initiator, - provision_job_id + provision_job_id, + provisioner_state ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, completed_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, completed_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id ` type InsertWorkspaceHistoryParams struct { @@ -1952,6 +1953,7 @@ type InsertWorkspaceHistoryParams struct { Transition WorkspaceTransition `db:"transition" json:"transition"` Initiator string `db:"initiator" json:"initiator"` ProvisionJobID uuid.UUID `db:"provision_job_id" json:"provision_job_id"` + ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` } func (q *sqlQuerier) InsertWorkspaceHistory(ctx context.Context, arg InsertWorkspaceHistoryParams) (WorkspaceHistory, error) { @@ -1966,6 +1968,7 @@ func (q *sqlQuerier) InsertWorkspaceHistory(ctx context.Context, arg InsertWorks arg.Transition, arg.Initiator, arg.ProvisionJobID, + arg.ProvisionerState, ) var i WorkspaceHistory err := row.Scan( diff --git a/go.mod b/go.mod index ace5013149946..cfe961dbd1544 100644 --- a/go.mod +++ b/go.mod @@ -35,8 +35,10 @@ require ( go.uber.org/goleak v1.1.12 golang.org/x/crypto v0.0.0-20220126234351-aa10faf2a1f8 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 google.golang.org/protobuf v1.27.1 + nhooyr.io/websocket v1.8.7 storj.io/drpc v0.0.29 ) @@ -72,6 +74,7 @@ require ( github.com/hashicorp/terraform-json v0.13.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/klauspost/compress v1.13.6 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect diff --git a/go.sum b/go.sum index 583c2a9236c3a..096b027cb5a69 100644 --- a/go.sum +++ b/go.sum @@ -432,6 +432,8 @@ github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmx github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -469,10 +471,13 @@ github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -506,6 +511,9 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556/go.mod h1:DL0ekTmBSTdlNF25Orwt/JMzqIq3EJ4MVa/J/uK64OY= github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= @@ -631,6 +639,7 @@ github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= @@ -789,10 +798,12 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -818,6 +829,7 @@ github.com/kylecarbs/terraform-exec v0.15.1-0.20220129210610-65894a884c09/go.mod github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -1160,6 +1172,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1 github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/unrolled/secure v1.0.9 h1:BWRuEb1vDrBFFDdbCnKkof3gZ35I/bnHGyt0LB0TNyQ= github.com/unrolled/secure v1.0.9/go.mod h1:fO+mEan+FLB0CdEnHf6Q4ZZVNqG+5fuLFnP8p0BXDPI= github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= @@ -1430,6 +1444,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1932,6 +1947,8 @@ modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA= modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA= modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/httpmw/projecthistoryparam.go b/httpmw/projecthistoryparam.go new file mode 100644 index 0000000000000..07f6a0e9010b5 --- /dev/null +++ b/httpmw/projecthistoryparam.go @@ -0,0 +1,60 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" +) + +type projectHistoryParamContextKey struct{} + +// ProjectHistoryParam returns the project history from the ExtractProjectHistoryParam handler. +func ProjectHistoryParam(r *http.Request) database.ProjectHistory { + projectHistory, ok := r.Context().Value(projectHistoryParamContextKey{}).(database.ProjectHistory) + if !ok { + panic("developer error: project history param middleware not provided") + } + return projectHistory +} + +// ExtractProjectHistoryParam grabs project history from the "projecthistory" URL parameter. +func ExtractProjectHistoryParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + project := ProjectParam(r) + projectHistoryName := chi.URLParam(r, "projecthistory") + if projectHistoryName == "" { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "project history name must be provided", + }) + return + } + projectHistory, err := db.GetProjectHistoryByProjectIDAndName(r.Context(), database.GetProjectHistoryByProjectIDAndNameParams{ + ProjectID: project.ID, + Name: projectHistoryName, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: fmt.Sprintf("project history %q does not exist", projectHistoryName), + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get project history: %s", err.Error()), + }) + return + } + + ctx := context.WithValue(r.Context(), projectHistoryParamContextKey{}, projectHistory) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/httpmw/projecthistoryparam_test.go b/httpmw/projecthistoryparam_test.go new file mode 100644 index 0000000000000..dbac93517637a --- /dev/null +++ b/httpmw/projecthistoryparam_test.go @@ -0,0 +1,161 @@ +package httpmw_test + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cryptorand" + "github.com/coder/coder/database" + "github.com/coder/coder/database/databasefake" + "github.com/coder/coder/httpmw" +) + +func TestProjectHistoryParam(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.Project) { + var ( + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + ) + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{ + Name: httpmw.AuthCookie, + Value: fmt.Sprintf("%s-%s", id, secret), + }) + userID, err := cryptorand.String(16) + require.NoError(t, err) + username, err := cryptorand.String(8) + require.NoError(t, err) + user, err := db.InsertUser(r.Context(), database.InsertUserParams{ + ID: userID, + Email: "testaccount@coder.com", + Name: "example", + LoginType: database.LoginTypeBuiltIn, + HashedPassword: hashed[:], + Username: username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + UserID: user.ID, + HashedSecret: hashed[:], + LastUsed: database.Now(), + ExpiresAt: database.Now().Add(time.Minute), + }) + require.NoError(t, err) + orgID, err := cryptorand.String(16) + require.NoError(t, err) + organization, err := db.InsertOrganization(r.Context(), database.InsertOrganizationParams{ + ID: orgID, + Name: "banana", + Description: "wowie", + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ + OrganizationID: orgID, + UserID: user.ID, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + project, err := db.InsertProject(context.Background(), database.InsertProjectParams{ + ID: uuid.New(), + OrganizationID: organization.ID, + Name: "moo", + }) + require.NoError(t, err) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("organization", organization.Name) + ctx.URLParams.Add("project", project.Name) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, project + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractOrganizationParam(db), + httpmw.ExtractProjectParam(db), + httpmw.ExtractProjectHistoryParam(db), + ) + rtr.Get("/", nil) + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractOrganizationParam(db), + httpmw.ExtractProjectParam(db), + httpmw.ExtractProjectHistoryParam(db), + ) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + chi.RouteContext(r.Context()).URLParams.Add("projecthistory", "nothin") + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("ProjectHistory", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractOrganizationParam(db), + httpmw.ExtractProjectParam(db), + httpmw.ExtractProjectHistoryParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.ProjectHistoryParam(r) + rw.WriteHeader(http.StatusOK) + }) + + r, project := setupAuthentication(db) + projectHistory, err := db.InsertProjectHistory(context.Background(), database.InsertProjectHistoryParams{ + ID: uuid.New(), + ProjectID: project.ID, + Name: "moo", + }) + require.NoError(t, err) + chi.RouteContext(r.Context()).URLParams.Add("projecthistory", projectHistory.Name) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} diff --git a/httpmw/workspacehistoryparam.go b/httpmw/workspacehistoryparam.go new file mode 100644 index 0000000000000..cd43823b22d9c --- /dev/null +++ b/httpmw/workspacehistoryparam.go @@ -0,0 +1,60 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" +) + +type workspaceHistoryParamContextKey struct{} + +// WorkspaceHistoryParam returns the workspace history from the ExtractWorkspaceHistoryParam handler. +func WorkspaceHistoryParam(r *http.Request) database.WorkspaceHistory { + workspaceHistory, ok := r.Context().Value(workspaceHistoryParamContextKey{}).(database.WorkspaceHistory) + if !ok { + panic("developer error: workspace history param middleware not provided") + } + return workspaceHistory +} + +// ExtractWorkspaceHistoryParam grabs workspace history from the "workspacehistory" URL parameter. +func ExtractWorkspaceHistoryParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + workspace := WorkspaceParam(r) + workspaceHistoryName := chi.URLParam(r, "workspacehistory") + if workspaceHistoryName == "" { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "workspace history name must be provided", + }) + return + } + workspaceHistory, err := db.GetWorkspaceHistoryByWorkspaceIDAndName(r.Context(), database.GetWorkspaceHistoryByWorkspaceIDAndNameParams{ + WorkspaceID: workspace.ID, + Name: workspaceHistoryName, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: fmt.Sprintf("workspace history %q does not exist", workspaceHistoryName), + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace history: %s", err.Error()), + }) + return + } + + ctx := context.WithValue(r.Context(), workspaceHistoryParamContextKey{}, workspaceHistory) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/httpmw/workspacehistoryparam_test.go b/httpmw/workspacehistoryparam_test.go new file mode 100644 index 0000000000000..6fef05ed91c13 --- /dev/null +++ b/httpmw/workspacehistoryparam_test.go @@ -0,0 +1,145 @@ +package httpmw_test + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cryptorand" + "github.com/coder/coder/database" + "github.com/coder/coder/database/databasefake" + "github.com/coder/coder/httpmw" +) + +func TestWorkspaceHistoryParam(t *testing.T) { + t.Parallel() + + setupAuthentication := func(db database.Store) (*http.Request, database.Workspace) { + var ( + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + ) + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{ + Name: httpmw.AuthCookie, + Value: fmt.Sprintf("%s-%s", id, secret), + }) + userID, err := cryptorand.String(16) + require.NoError(t, err) + username, err := cryptorand.String(8) + require.NoError(t, err) + user, err := db.InsertUser(r.Context(), database.InsertUserParams{ + ID: userID, + Email: "testaccount@coder.com", + Name: "example", + LoginType: database.LoginTypeBuiltIn, + HashedPassword: hashed[:], + Username: username, + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + UserID: user.ID, + HashedSecret: hashed[:], + LastUsed: database.Now(), + ExpiresAt: database.Now().Add(time.Minute), + }) + require.NoError(t, err) + workspace, err := db.InsertWorkspace(context.Background(), database.InsertWorkspaceParams{ + ID: uuid.New(), + ProjectID: uuid.New(), + OwnerID: user.ID, + Name: "potato", + }) + require.NoError(t, err) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("user", userID) + ctx.URLParams.Add("workspace", workspace.Name) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, workspace + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractUserParam(db), + httpmw.ExtractWorkspaceParam(db), + httpmw.ExtractWorkspaceHistoryParam(db), + ) + rtr.Get("/", nil) + r, _ := setupAuthentication(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractUserParam(db), + httpmw.ExtractWorkspaceParam(db), + httpmw.ExtractWorkspaceHistoryParam(db), + ) + rtr.Get("/", nil) + + r, _ := setupAuthentication(db) + chi.RouteContext(r.Context()).URLParams.Add("workspacehistory", "nothin") + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("WorkspaceHistory", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKey(db, nil), + httpmw.ExtractUserParam(db), + httpmw.ExtractWorkspaceParam(db), + httpmw.ExtractWorkspaceHistoryParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.WorkspaceHistoryParam(r) + rw.WriteHeader(http.StatusOK) + }) + + r, workspace := setupAuthentication(db) + workspaceHistory, err := db.InsertWorkspaceHistory(context.Background(), database.InsertWorkspaceHistoryParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + Name: "moo", + }) + require.NoError(t, err) + chi.RouteContext(r.Context()).URLParams.Add("workspacehistory", workspaceHistory.Name) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} From eea12bf189fc7d6cbf389503ec1e5a3d0730a8ae Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 19:51:40 +0000 Subject: [PATCH 2/2] 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. --- .vscode/settings.json | 3 +- coderd/coderd.go | 57 +++++---- coderd/projecthistory.go | 118 ++++++++++++++++++ coderd/projecthistory_test.go | 101 +++++++++++++++ coderd/projects.go | 126 ++++--------------- coderd/projects_test.go | 85 ------------- coderd/users.go | 36 +++--- coderd/workspacehistory.go | 182 +++++++++++++++++++++++++++ coderd/workspacehistory_test.go | 135 ++++++++++++++++++++ coderd/workspaces.go | 215 ++------------------------------ coderd/workspaces_test.go | 109 +--------------- codersdk/projects.go | 2 +- codersdk/projects_test.go | 4 +- go.mod | 4 +- 14 files changed, 623 insertions(+), 554 deletions(-) create mode 100644 coderd/projecthistory.go create mode 100644 coderd/projecthistory_test.go create mode 100644 coderd/workspacehistory.go create mode 100644 coderd/workspacehistory_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 53c22854a612c..db290aedc5202 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,6 @@ "cmd": "make gen" } ] - } + }, + "cSpell.words": ["coderd", "coderdtest", "codersdk", "httpmw", "oneof", "stretchr", "xerrors"] } diff --git a/coderd/coderd.go b/coderd/coderd.go index b6c01e3e30745..05569b8aeb5e1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -16,18 +16,14 @@ import ( type Options struct { Logger slog.Logger Database database.Store + Pubsub database.Pubsub } // New constructs the Coder API into an HTTP handler. func New(options *Options) http.Handler { - projects := &projects{ - Database: options.Database, - } - users := &users{ - Database: options.Database, - } - workspaces := &workspaces{ + api := &api{ Database: options.Database, + Pubsub: options.Pubsub, } r := chi.NewRouter() @@ -37,38 +33,38 @@ func New(options *Options) http.Handler { Message: "👋", }) }) - r.Post("/login", users.loginWithPassword) - r.Post("/logout", users.logout) + r.Post("/login", api.postLogin) + r.Post("/logout", api.postLogout) // Used for setup. - r.Post("/user", users.createInitialUser) + r.Post("/user", api.postUser) r.Route("/users", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), ) - r.Post("/", users.createUser) + r.Post("/", api.postUsers) r.Group(func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/{user}", users.user) - r.Get("/{user}/organizations", users.userOrganizations) + r.Get("/{user}", api.userByName) + r.Get("/{user}/organizations", api.organizationsByUser) }) }) r.Route("/projects", func(r chi.Router) { r.Use( httpmw.ExtractAPIKey(options.Database, nil), ) - r.Get("/", projects.allProjects) + r.Get("/", api.projects) r.Route("/{organization}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationParam(options.Database)) - r.Get("/", projects.allProjectsForOrganization) - r.Post("/", projects.createProject) + r.Get("/", api.projectsByOrganization) + r.Post("/", api.postProjectsByOrganization) r.Route("/{project}", func(r chi.Router) { r.Use(httpmw.ExtractProjectParam(options.Database)) - r.Get("/", projects.project) + r.Get("/", api.projectByOrganization) + r.Get("/workspaces", api.workspacesByProject) r.Route("/history", func(r chi.Router) { - r.Get("/", projects.allProjectHistory) - r.Post("/", projects.createProjectHistory) + r.Get("/", api.projectHistoryByOrganization) + r.Post("/", api.postProjectHistoryByOrganization) }) - r.Get("/workspaces", workspaces.allWorkspacesForProject) }) }) }) @@ -77,18 +73,18 @@ func New(options *Options) http.Handler { // their respective routes. eg. /orgs//workspaces r.Route("/workspaces", func(r chi.Router) { r.Use(httpmw.ExtractAPIKey(options.Database, nil)) - r.Get("/", workspaces.listAllWorkspaces) + r.Get("/", api.workspaces) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/", workspaces.listAllWorkspaces) - r.Post("/", workspaces.createWorkspaceForUser) + r.Get("/", api.workspaces) + r.Post("/", api.postWorkspaceByUser) r.Route("/{workspace}", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceParam(options.Database)) - r.Get("/", workspaces.singleWorkspace) + r.Get("/", api.workspaceByUser) r.Route("/history", func(r chi.Router) { - r.Post("/", workspaces.createWorkspaceHistory) - r.Get("/", workspaces.listAllWorkspaceHistory) - r.Get("/latest", workspaces.latestWorkspaceHistory) + r.Post("/", api.postWorkspaceHistoryByUser) + r.Get("/", api.workspaceHistoryByUser) + r.Get("/latest", api.latestWorkspaceHistoryByUser) }) }) }) @@ -97,3 +93,10 @@ func New(options *Options) http.Handler { r.NotFound(site.Handler().ServeHTTP) return r } + +// API contains all route handlers. Only HTTP handlers should +// be added to this struct for code clarity. +type api struct { + Database database.Store + Pubsub database.Pubsub +} diff --git a/coderd/projecthistory.go b/coderd/projecthistory.go new file mode 100644 index 0000000000000..a5057b8c514f0 --- /dev/null +++ b/coderd/projecthistory.go @@ -0,0 +1,118 @@ +package coderd + +import ( + "archive/tar" + "bytes" + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" + "github.com/coder/coder/httpmw" +) + +// ProjectHistory is the JSON representation of Coder project version history. +type ProjectHistory struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + StorageMethod database.ProjectStorageMethod `json:"storage_method"` +} + +// CreateProjectHistoryRequest enables callers to create a new Project Version. +type CreateProjectHistoryRequest struct { + StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"` + StorageSource []byte `json:"storage_source" validate:"max=1048576,required"` +} + +// Lists history for a single project. +func (api *api) projectHistoryByOrganization(rw http.ResponseWriter, r *http.Request) { + project := httpmw.ProjectParam(r) + + history, err := api.Database.GetProjectHistoryByProjectID(r.Context(), project.ID) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get project history: %s", err), + }) + return + } + apiHistory := make([]ProjectHistory, 0) + for _, version := range history { + apiHistory = append(apiHistory, convertProjectHistory(version)) + } + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiHistory) +} + +// 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. +func (api *api) postProjectHistoryByOrganization(rw http.ResponseWriter, r *http.Request) { + var createProjectVersion CreateProjectHistoryRequest + if !httpapi.Read(rw, r, &createProjectVersion) { + return + } + + switch createProjectVersion.StorageMethod { + case database.ProjectStorageMethodInlineArchive: + tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource)) + _, err := tarReader.Next() + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "the archive must be a tar", + }) + return + } + default: + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod), + }) + return + } + + 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(), + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("insert project history: %s", err), + }) + return + } + + // TODO: A job to process the new version should occur here. + + render.Status(r, http.StatusCreated) + render.JSON(rw, r, convertProjectHistory(history)) +} + +func convertProjectHistory(history database.ProjectHistory) ProjectHistory { + return ProjectHistory{ + ID: history.ID, + ProjectID: history.ProjectID, + CreatedAt: history.CreatedAt, + UpdatedAt: history.UpdatedAt, + Name: history.Name, + } +} diff --git a/coderd/projecthistory_test.go b/coderd/projecthistory_test.go new file mode 100644 index 0000000000000..4c9b727fbe358 --- /dev/null +++ b/coderd/projecthistory_test.go @@ -0,0 +1,101 @@ +package coderd_test + +import ( + "archive/tar" + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/database" +) + +func TestProjectHistory(t *testing.T) { + t.Parallel() + + t.Run("NoHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) + require.NoError(t, err) + require.Len(t, versions, 0) + }) + + t.Run("CreateHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + err = writer.WriteHeader(&tar.Header{ + Name: "file", + Size: 1 << 10, + }) + 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{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: buffer.Bytes(), + }) + require.NoError(t, err) + versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) + require.NoError(t, err) + require.Len(t, versions, 1) + }) + + t.Run("CreateHistoryArchiveTooBig", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + err = writer.WriteHeader(&tar.Header{ + Name: "file", + Size: 1 << 21, + }) + require.NoError(t, err) + _, err = writer.Write(make([]byte, 1<<21)) + require.NoError(t, err) + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: buffer.Bytes(), + }) + require.Error(t, err) + }) + + t.Run("CreateHistoryInvalidArchive", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "someproject", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: []byte{}, + }) + require.Error(t, err) + }) +} diff --git a/coderd/projects.go b/coderd/projects.go index 5ef2ea5067b6a..b7e161adcd7bc 100644 --- a/coderd/projects.go +++ b/coderd/projects.go @@ -1,19 +1,14 @@ package coderd import ( - "archive/tar" - "bytes" "database/sql" "errors" "fmt" "net/http" - "time" "github.com/go-chi/render" "github.com/google/uuid" - "github.com/moby/moby/pkg/namesgenerator" - "github.com/coder/coder/database" "github.com/coder/coder/httpapi" "github.com/coder/coder/httpmw" @@ -24,36 +19,16 @@ import ( // abstracted for ease of change later on. type Project database.Project -// ProjectHistory is the JSON representation of Coder project version history. -type ProjectHistory struct { - ID uuid.UUID `json:"id"` - ProjectID uuid.UUID `json:"project_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Name string `json:"name"` - StorageMethod database.ProjectStorageMethod `json:"storage_method"` -} - // CreateProjectRequest enables callers to create a new Project. type CreateProjectRequest struct { Name string `json:"name" validate:"username,required"` Provisioner database.ProvisionerType `json:"provisioner" validate:"oneof=terraform cdr-basic,required"` } -// CreateProjectVersionRequest enables callers to create a new Project Version. -type CreateProjectVersionRequest struct { - StorageMethod database.ProjectStorageMethod `json:"storage_method" validate:"oneof=inline-archive,required"` - StorageSource []byte `json:"storage_source" validate:"max=1048576,required"` -} - -type projects struct { - Database database.Store -} - // Lists all projects the authenticated user has access to. -func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) { +func (api *api) projects(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) - organizations, err := p.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID) + organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), apiKey.UserID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get organizations: %s", err.Error()), @@ -64,7 +39,7 @@ func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) { for _, organization := range organizations { organizationIDs = append(organizationIDs, organization.ID) } - projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs) + projects, err := api.Database.GetProjectsByOrganizationIDs(r.Context(), organizationIDs) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -79,9 +54,9 @@ func (p *projects) allProjects(rw http.ResponseWriter, r *http.Request) { } // Lists all projects in an organization. -func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Request) { +func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request) { organization := httpmw.OrganizationParam(r) - projects, err := p.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID}) + projects, err := api.Database.GetProjectsByOrganizationIDs(r.Context(), []string{organization.ID}) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -96,13 +71,13 @@ func (p *projects) allProjectsForOrganization(rw http.ResponseWriter, r *http.Re } // Creates a new project in an organization. -func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) { +func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Request) { var createProject CreateProjectRequest if !httpapi.Read(rw, r, &createProject) { return } organization := httpmw.OrganizationParam(r) - _, err := p.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{ + _, err := api.Database.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{ OrganizationID: organization.ID, Name: createProject.Name, }) @@ -123,7 +98,7 @@ func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) { return } - project, err := p.Database.InsertProject(r.Context(), database.InsertProjectParams{ + project, err := api.Database.InsertProject(r.Context(), database.InsertProjectParams{ ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), @@ -142,92 +117,35 @@ func (p *projects) createProject(rw http.ResponseWriter, r *http.Request) { } // Returns a single project. -func (*projects) project(rw http.ResponseWriter, r *http.Request) { +func (*api) projectByOrganization(rw http.ResponseWriter, r *http.Request) { project := httpmw.ProjectParam(r) render.Status(r, http.StatusOK) render.JSON(rw, r, project) } -// Lists history for a single project. -func (p *projects) allProjectHistory(rw http.ResponseWriter, r *http.Request) { +// Returns all workspaces for a specific project. +func (api *api) workspacesByProject(rw http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APIKey(r) project := httpmw.ProjectParam(r) - - history, err := p.Database.GetProjectHistoryByProjectID(r.Context(), project.ID) + workspaces, err := api.Database.GetWorkspacesByProjectAndUserID(r.Context(), database.GetWorkspacesByProjectAndUserIDParams{ + OwnerID: apiKey.UserID, + ProjectID: project.ID, + }) if errors.Is(err, sql.ErrNoRows) { err = nil } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get project history: %s", err), + Message: fmt.Sprintf("get workspaces: %s", err), }) return } - apiHistory := make([]ProjectHistory, 0) - for _, version := range history { - apiHistory = append(apiHistory, convertProjectHistory(version)) - } - render.Status(r, http.StatusOK) - render.JSON(rw, r, apiHistory) -} -// 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. -func (p *projects) createProjectHistory(rw http.ResponseWriter, r *http.Request) { - var createProjectVersion CreateProjectVersionRequest - if !httpapi.Read(rw, r, &createProjectVersion) { - return - } - - switch createProjectVersion.StorageMethod { - case database.ProjectStorageMethodInlineArchive: - tarReader := tar.NewReader(bytes.NewReader(createProjectVersion.StorageSource)) - _, err := tarReader.Next() - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "the archive must be a tar", - }) - return - } - default: - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("unsupported storage method %s", createProjectVersion.StorageMethod), - }) - return - } - - project := httpmw.ProjectParam(r) - history, err := p.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(), - }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("insert project history: %s", err), - }) - return - } - - // TODO: A job to process the new version should occur here. - - render.Status(r, http.StatusCreated) - render.JSON(rw, r, convertProjectHistory(history)) -} - -func convertProjectHistory(history database.ProjectHistory) ProjectHistory { - return ProjectHistory{ - ID: history.ID, - ProjectID: history.ProjectID, - CreatedAt: history.CreatedAt, - UpdatedAt: history.UpdatedAt, - Name: history.Name, + apiWorkspaces := make([]Workspace, 0, len(workspaces)) + for _, workspace := range workspaces { + apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace)) } + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiWorkspaces) } diff --git a/coderd/projects_test.go b/coderd/projects_test.go index cd02703f64b6e..98261ba105cf7 100644 --- a/coderd/projects_test.go +++ b/coderd/projects_test.go @@ -1,8 +1,6 @@ package coderd_test import ( - "archive/tar" - "bytes" "context" "testing" @@ -94,87 +92,4 @@ func TestProjects(t *testing.T) { _, err = server.Client.Project(context.Background(), user.Organization, project.Name) require.NoError(t, err) }) - - t.Run("NoVersions", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) - require.NoError(t, err) - require.Len(t, versions, 0) - }) - - t.Run("CreateVersion", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - var buffer bytes.Buffer - writer := tar.NewWriter(&buffer) - err = writer.WriteHeader(&tar.Header{ - Name: "file", - Size: 1 << 10, - }) - 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.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: buffer.Bytes(), - }) - require.NoError(t, err) - versions, err := server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) - require.NoError(t, err) - require.Len(t, versions, 1) - }) - - t.Run("CreateVersionArchiveTooBig", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - var buffer bytes.Buffer - writer := tar.NewWriter(&buffer) - err = writer.WriteHeader(&tar.Header{ - Name: "file", - Size: 1 << 21, - }) - require.NoError(t, err) - _, err = writer.Write(make([]byte, 1<<21)) - require.NoError(t, err) - _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: buffer.Bytes(), - }) - require.Error(t, err) - }) - - t.Run("CreateVersionInvalidArchive", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, err := server.Client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ - Name: "someproject", - Provisioner: database.ProvisionerTypeTerraform, - }) - require.NoError(t, err) - _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: []byte{}, - }) - require.Error(t, err) - }) } diff --git a/coderd/users.go b/coderd/users.go index 6fe812d9decdc..8aafc1f1bd9e0 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -55,18 +55,14 @@ type LoginWithPasswordResponse struct { SessionToken string `json:"session_token" validate:"required"` } -type users struct { - Database database.Store -} - // Creates the initial user for a Coder deployment. -func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { +func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { var createUser CreateInitialUserRequest if !httpapi.Read(rw, r, &createUser) { return } // This should only function for the first user. - userCount, err := users.Database.GetUserCount(r.Context()) + userCount, err := api.Database.GetUserCount(r.Context()) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get user count: %s", err.Error()), @@ -90,8 +86,8 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { // Create the user, organization, and membership to the user. var user database.User - err = users.Database.InTx(func(s database.Store) error { - user, err = users.Database.InsertUser(r.Context(), database.InsertUserParams{ + err = api.Database.InTx(func(s database.Store) error { + user, err = api.Database.InsertUser(r.Context(), database.InsertUserParams{ ID: uuid.NewString(), Email: createUser.Email, HashedPassword: []byte(hashedPassword), @@ -103,7 +99,7 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create user: %w", err) } - organization, err := users.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{ + organization, err := api.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{ ID: uuid.NewString(), Name: createUser.Organization, CreatedAt: database.Now(), @@ -112,7 +108,7 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create organization: %w", err) } - _, err = users.Database.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ + _, err = api.Database.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, UserID: user.ID, CreatedAt: database.Now(), @@ -136,12 +132,12 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) { } // Creates a new user. -func (users *users) createUser(rw http.ResponseWriter, r *http.Request) { +func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) { var createUser CreateUserRequest if !httpapi.Read(rw, r, &createUser) { return } - _, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + _, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Username: createUser.Username, Email: createUser.Email, }) @@ -166,7 +162,7 @@ func (users *users) createUser(rw http.ResponseWriter, r *http.Request) { return } - user, err := users.Database.InsertUser(r.Context(), database.InsertUserParams{ + user, err := api.Database.InsertUser(r.Context(), database.InsertUserParams{ ID: uuid.NewString(), Email: createUser.Email, HashedPassword: []byte(hashedPassword), @@ -188,17 +184,17 @@ func (users *users) createUser(rw http.ResponseWriter, r *http.Request) { // Returns the parameterized user requested. All validation // is completed in the middleware for this route. -func (*users) user(rw http.ResponseWriter, r *http.Request) { +func (*api) userByName(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) render.JSON(rw, r, convertUser(user)) } // Returns organizations the parameterized user has access to. -func (users *users) userOrganizations(rw http.ResponseWriter, r *http.Request) { +func (api *api) organizationsByUser(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - organizations, err := users.Database.GetOrganizationsByUserID(r.Context(), user.ID) + organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get organizations: %s", err.Error()), @@ -216,12 +212,12 @@ func (users *users) userOrganizations(rw http.ResponseWriter, r *http.Request) { } // Authenticates the user with an email and password. -func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) { +func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) { var loginWithPassword LoginWithPasswordRequest if !httpapi.Read(rw, r, &loginWithPassword) { return } - user, err := users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ Email: loginWithPassword.Email, }) if errors.Is(err, sql.ErrNoRows) { @@ -260,7 +256,7 @@ func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) { } hashed := sha256.Sum256([]byte(keySecret)) - _, err = users.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: keyID, UserID: user.ID, ExpiresAt: database.Now().Add(24 * time.Hour), @@ -293,7 +289,7 @@ func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) { } // Clear the user's session cookie -func (*users) logout(rw http.ResponseWriter, r *http.Request) { +func (*api) postLogout(rw http.ResponseWriter, r *http.Request) { // Get a blank token cookie cookie := &http.Cookie{ // MaxAge < 0 means to delete the cookie now diff --git a/coderd/workspacehistory.go b/coderd/workspacehistory.go new file mode 100644 index 0000000000000..32eba2e98e2da --- /dev/null +++ b/coderd/workspacehistory.go @@ -0,0 +1,182 @@ +package coderd + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" + "github.com/coder/coder/httpmw" +) + +// WorkspaceHistory is an at-point representation of a workspace state. +// Iterate on before/after to determine a chronological history. +type WorkspaceHistory struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt time.Time `json:"completed_at"` + WorkspaceID uuid.UUID `json:"workspace_id"` + ProjectHistoryID uuid.UUID `json:"project_history_id"` + BeforeID uuid.UUID `json:"before_id"` + AfterID uuid.UUID `json:"after_id"` + Transition database.WorkspaceTransition `json:"transition"` + Initiator string `json:"initiator"` +} + +// CreateWorkspaceHistoryRequest provides options to update the latest workspace history. +type CreateWorkspaceHistoryRequest struct { + ProjectHistoryID uuid.UUID `json:"project_history_id" validate:"required"` + Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"` +} + +// Begins transitioning a workspace to new state. This queues a provision job to asynchronously +// update the underlying infrastructure. Only one historical transition can occur at a time. +func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) { + var createBuild CreateWorkspaceHistoryRequest + if !httpapi.Read(rw, r, &createBuild) { + return + } + user := httpmw.UserParam(r) + workspace := httpmw.WorkspaceParam(r) + projectHistory, err := api.Database.GetProjectHistoryByID(r.Context(), createBuild.ProjectHistoryID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "project history not found", + Errors: []httpapi.Error{{ + Field: "project_history_id", + Code: "exists", + }}, + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get project history: %s", err), + }) + return + } + + // Store prior history ID if it exists to update it after we create new! + priorHistoryID := uuid.NullUUID{} + priorHistory, err := api.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if err == nil { + if !priorHistory.CompletedAt.Valid { + httpapi.Write(rw, http.StatusConflict, httpapi.Response{ + Message: "a workspace build is already active", + }) + return + } + + priorHistoryID = uuid.NullUUID{ + UUID: priorHistory.ID, + Valid: true, + } + } + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get prior workspace history: %s", err), + }) + return + } + + var workspaceHistory database.WorkspaceHistory + // This must happen in a transaction to ensure history can be inserted, and + // the prior history can update it's "after" column to point at the new. + err = api.Database.InTx(func(db database.Store) error { + workspaceHistory, err = db.InsertWorkspaceHistory(r.Context(), database.InsertWorkspaceHistoryParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + WorkspaceID: workspace.ID, + ProjectHistoryID: projectHistory.ID, + BeforeID: priorHistoryID, + Initiator: user.ID, + Transition: createBuild.Transition, + // This should create a provision job once that gets implemented! + ProvisionJobID: uuid.New(), + }) + if err != nil { + return xerrors.Errorf("insert workspace history: %w", err) + } + + if priorHistoryID.Valid { + // Update the prior history entries "after" column. + err = db.UpdateWorkspaceHistoryByID(r.Context(), database.UpdateWorkspaceHistoryByIDParams{ + ID: priorHistory.ID, + UpdatedAt: database.Now(), + AfterID: uuid.NullUUID{ + UUID: workspaceHistory.ID, + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update prior workspace history: %w", err) + } + } + + return nil + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: err.Error(), + }) + return + } + + render.Status(r, http.StatusCreated) + render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory)) +} + +// Returns all workspace history. This is not sorted. Use before/after to chronologically sort. +func (api *api) workspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + + histories, err := api.Database.GetWorkspaceHistoryByWorkspaceID(r.Context(), workspace.ID) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace history: %s", err), + }) + return + } + + apiHistory := make([]WorkspaceHistory, 0, len(histories)) + for _, history := range histories { + apiHistory = append(apiHistory, convertWorkspaceHistory(history)) + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, apiHistory) +} + +// Returns the latest workspace history. This works by querying for history without "after" set. +func (api *api) latestWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + + history, err := api.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: "workspace has no history", + }) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace history: %s", err), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, convertWorkspaceHistory(history)) +} diff --git a/coderd/workspacehistory_test.go b/coderd/workspacehistory_test.go new file mode 100644 index 0000000000000..773de1a5b5a95 --- /dev/null +++ b/coderd/workspacehistory_test.go @@ -0,0 +1,135 @@ +package coderd_test + +import ( + "archive/tar" + "bytes" + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/database" +) + +func TestWorkspaceHistory(t *testing.T) { + t.Parallel() + + setupProjectAndWorkspace := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest) (coderd.Project, coderd.Workspace) { + project, err := client.CreateProject(context.Background(), user.Organization, coderd.CreateProjectRequest{ + Name: "banana", + Provisioner: database.ProvisionerTypeTerraform, + }) + require.NoError(t, err) + workspace, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ + Name: "example", + ProjectID: project.ID, + }) + require.NoError(t, err) + return project, workspace + } + + setupProjectHistory := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project) coderd.ProjectHistory { + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + err := writer.WriteHeader(&tar.Header{ + Name: "file", + Size: 1 << 10, + }) + require.NoError(t, err) + _, err = writer.Write(make([]byte, 1<<10)) + require.NoError(t, err) + projectHistory, err := client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ + StorageMethod: database.ProjectStorageMethodInlineArchive, + StorageSource: buffer.Bytes(), + }) + require.NoError(t, err) + return projectHistory + } + + t.Run("AllHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + history, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + require.Len(t, history, 0) + projectVersion := setupProjectHistory(t, server.Client, user, project) + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectVersion.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + history, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + require.Len(t, history, 1) + }) + + t.Run("LatestHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + _, err := server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + require.Error(t, err) + projectVersion := setupProjectHistory(t, server.Client, user, project) + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectVersion.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + require.NoError(t, err) + }) + + t.Run("CreateHistory", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + projectHistory := setupProjectHistory(t, server.Client, user, project) + + _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectHistory.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + }) + + t.Run("CreateHistoryAlreadyInProgress", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + project, workspace := setupProjectAndWorkspace(t, server.Client, user) + projectHistory := setupProjectHistory(t, server.Client, user, project) + + _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectHistory.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.NoError(t, err) + + _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: projectHistory.ID, + Transition: database.WorkspaceTransitionCreate, + }) + require.Error(t, err) + }) + + t.Run("CreateHistoryInvalidProjectVersion", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + user := server.RandomInitialUser(t) + _, workspace := setupProjectAndWorkspace(t, server.Client, user) + + _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ + ProjectHistoryID: uuid.New(), + Transition: database.WorkspaceTransitionCreate, + }) + require.Error(t, err) + }) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f12633a5611bf..60504fb2cc184 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -5,11 +5,9 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/go-chi/render" "github.com/google/uuid" - "golang.org/x/xerrors" "github.com/coder/coder/database" "github.com/coder/coder/httpapi" @@ -20,67 +18,16 @@ import ( // project versions, and can be updated. type Workspace database.Workspace -// WorkspaceHistory is an at-point representation of a workspace state. -// Iterate on before/after to determine a chronological history. -type WorkspaceHistory struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CompletedAt time.Time `json:"completed_at"` - WorkspaceID uuid.UUID `json:"workspace_id"` - ProjectHistoryID uuid.UUID `json:"project_history_id"` - BeforeID uuid.UUID `json:"before_id"` - AfterID uuid.UUID `json:"after_id"` - Transition database.WorkspaceTransition `json:"transition"` - Initiator string `json:"initiator"` -} - // CreateWorkspaceRequest provides options for creating a new workspace. type CreateWorkspaceRequest struct { ProjectID uuid.UUID `json:"project_id" validate:"required"` Name string `json:"name" validate:"username,required"` } -// CreateWorkspaceHistoryRequest provides options to update the latest workspace history. -type CreateWorkspaceHistoryRequest struct { - ProjectHistoryID uuid.UUID `json:"project_history_id" validate:"required"` - Transition database.WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"` -} - -type workspaces struct { - Database database.Store -} - // Returns all workspaces across all projects and organizations. -func (w *workspaces) listAllWorkspaces(rw http.ResponseWriter, r *http.Request) { +func (api *api) workspaces(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) - workspaces, err := w.Database.GetWorkspacesByUserID(r.Context(), apiKey.UserID) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspaces: %s", err), - }) - return - } - - apiWorkspaces := make([]Workspace, 0, len(workspaces)) - for _, workspace := range workspaces { - apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace)) - } - render.Status(r, http.StatusOK) - render.JSON(rw, r, apiWorkspaces) -} - -// Returns all workspaces for a specific project. -func (w *workspaces) allWorkspacesForProject(rw http.ResponseWriter, r *http.Request) { - apiKey := httpmw.APIKey(r) - project := httpmw.ProjectParam(r) - workspaces, err := w.Database.GetWorkspacesByProjectAndUserID(r.Context(), database.GetWorkspacesByProjectAndUserIDParams{ - OwnerID: apiKey.UserID, - ProjectID: project.ID, - }) + workspaces, err := api.Database.GetWorkspacesByUserID(r.Context(), apiKey.UserID) if errors.Is(err, sql.ErrNoRows) { err = nil } @@ -100,13 +47,13 @@ func (w *workspaces) allWorkspacesForProject(rw http.ResponseWriter, r *http.Req } // Create a new workspace for the currently authenticated user. -func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Request) { +func (api *api) postWorkspaceByUser(rw http.ResponseWriter, r *http.Request) { var createWorkspace CreateWorkspaceRequest if !httpapi.Read(rw, r, &createWorkspace) { return } apiKey := httpmw.APIKey(r) - project, err := w.Database.GetProjectByID(r.Context(), createWorkspace.ProjectID) + project, err := api.Database.GetProjectByID(r.Context(), createWorkspace.ProjectID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("project %q doesn't exist", createWorkspace.ProjectID.String()), @@ -123,7 +70,7 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ }) return } - _, err = w.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ + _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ OrganizationID: project.OrganizationID, UserID: apiKey.UserID, }) @@ -140,13 +87,13 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ return } - workspace, err := w.Database.GetWorkspaceByUserIDAndName(r.Context(), database.GetWorkspaceByUserIDAndNameParams{ + workspace, err := api.Database.GetWorkspaceByUserIDAndName(r.Context(), database.GetWorkspaceByUserIDAndNameParams{ OwnerID: apiKey.UserID, Name: createWorkspace.Name, }) if err == nil { // If the workspace already exists, don't allow creation. - project, err := w.Database.GetProjectByID(r.Context(), workspace.ProjectID) + project, err := api.Database.GetProjectByID(r.Context(), workspace.ProjectID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("find project for conflicting workspace name %q: %s", createWorkspace.Name, err), @@ -171,7 +118,7 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ } // Workspaces are created without any versions. - workspace, err = w.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{ + workspace, err = api.Database.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{ ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), @@ -191,157 +138,13 @@ func (w *workspaces) createWorkspaceForUser(rw http.ResponseWriter, r *http.Requ } // Returns a single singleWorkspace. -func (*workspaces) singleWorkspace(rw http.ResponseWriter, r *http.Request) { +func (*api) workspaceByUser(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) render.Status(r, http.StatusOK) render.JSON(rw, r, convertWorkspace(workspace)) } -// Returns all workspace history. This is not sorted. Use before/after to chronologically sort. -func (w *workspaces) listAllWorkspaceHistory(rw http.ResponseWriter, r *http.Request) { - workspace := httpmw.WorkspaceParam(r) - - histories, err := w.Database.GetWorkspaceHistoryByWorkspaceID(r.Context(), workspace.ID) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspace history: %s", err), - }) - return - } - - apiHistory := make([]WorkspaceHistory, 0, len(histories)) - for _, history := range histories { - apiHistory = append(apiHistory, convertWorkspaceHistory(history)) - } - - render.Status(r, http.StatusOK) - render.JSON(rw, r, apiHistory) -} - -// Returns the latest workspace history. This works by querying for history without "after" set. -func (w *workspaces) latestWorkspaceHistory(rw http.ResponseWriter, r *http.Request) { - workspace := httpmw.WorkspaceParam(r) - - history, err := w.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ - Message: "workspace has no history", - }) - return - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get workspace history: %s", err), - }) - return - } - - render.Status(r, http.StatusOK) - render.JSON(rw, r, convertWorkspaceHistory(history)) -} - -// Begins transitioning a workspace to new state. This queues a provision job to asyncronously -// update the underlying infrastructure. Only one historical transition can occur at a time. -func (w *workspaces) createWorkspaceHistory(rw http.ResponseWriter, r *http.Request) { - var createBuild CreateWorkspaceHistoryRequest - if !httpapi.Read(rw, r, &createBuild) { - return - } - user := httpmw.UserParam(r) - workspace := httpmw.WorkspaceParam(r) - projectHistory, err := w.Database.GetProjectHistoryByID(r.Context(), createBuild.ProjectHistoryID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "project history not found", - Errors: []httpapi.Error{{ - Field: "project_history_id", - Code: "exists", - }}, - }) - return - } - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get project history: %s", err), - }) - return - } - - // Store prior history ID if it exists to update it after we create new! - priorHistoryID := uuid.NullUUID{} - priorHistory, err := w.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) - if err == nil { - if !priorHistory.CompletedAt.Valid { - httpapi.Write(rw, http.StatusConflict, httpapi.Response{ - Message: "a workspace build is already active", - }) - return - } - - priorHistoryID = uuid.NullUUID{ - UUID: priorHistory.ID, - Valid: true, - } - } - if !errors.Is(err, sql.ErrNoRows) { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: fmt.Sprintf("get prior workspace history: %s", err), - }) - return - } - - var workspaceHistory database.WorkspaceHistory - // This must happen in a transaction to ensure history can be inserted, and - // the prior history can update it's "after" column to point at the new. - err = w.Database.InTx(func(db database.Store) error { - workspaceHistory, err = db.InsertWorkspaceHistory(r.Context(), database.InsertWorkspaceHistoryParams{ - ID: uuid.New(), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - WorkspaceID: workspace.ID, - ProjectHistoryID: projectHistory.ID, - BeforeID: priorHistoryID, - Initiator: user.ID, - Transition: createBuild.Transition, - // This should create a provision job once that gets implemented! - ProvisionJobID: uuid.New(), - }) - if err != nil { - return xerrors.Errorf("insert workspace history: %w", err) - } - - if priorHistoryID.Valid { - // Update the prior history entries "after" column. - err = db.UpdateWorkspaceHistoryByID(r.Context(), database.UpdateWorkspaceHistoryByIDParams{ - ID: priorHistory.ID, - UpdatedAt: database.Now(), - AfterID: uuid.NullUUID{ - UUID: workspaceHistory.ID, - Valid: true, - }, - }) - if err != nil { - return xerrors.Errorf("update prior workspace history: %w", err) - } - } - - return nil - }) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: err.Error(), - }) - return - } - - render.Status(r, http.StatusCreated) - render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory)) -} - // Converts the internal workspace representation to a public external-facing model. func convertWorkspace(workspace database.Workspace) Workspace { return Workspace(workspace) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 37e40a284e2f9..2ff351813694a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1,8 +1,6 @@ package coderd_test import ( - "archive/tar" - "bytes" "context" "testing" @@ -34,31 +32,13 @@ func TestWorkspaces(t *testing.T) { }) require.NoError(t, err) workspace, err := client.CreateWorkspace(context.Background(), "", coderd.CreateWorkspaceRequest{ - Name: "hiii", + Name: "example", ProjectID: project.ID, }) require.NoError(t, err) return project, workspace } - setupProjectVersion := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project) coderd.ProjectHistory { - var buffer bytes.Buffer - writer := tar.NewWriter(&buffer) - err := writer.WriteHeader(&tar.Header{ - Name: "file", - Size: 1 << 10, - }) - require.NoError(t, err) - _, err = writer.Write(make([]byte, 1<<10)) - require.NoError(t, err) - projectHistory, err := client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectVersionRequest{ - StorageMethod: database.ProjectStorageMethodInlineArchive, - StorageSource: buffer.Bytes(), - }) - require.NoError(t, err) - return projectHistory - } - t.Run("List", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) @@ -132,12 +112,12 @@ func TestWorkspaces(t *testing.T) { _, err = server.Client.CreateUser(context.Background(), coderd.CreateUserRequest{ Email: "hello@ok.io", Username: "example", - Password: "wowowow", + Password: "password", }) require.NoError(t, err) token, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{ Email: "hello@ok.io", - Password: "wowowow", + Password: "password", }) require.NoError(t, err) err = server.Client.SetSessionToken(token.SessionToken) @@ -169,87 +149,4 @@ func TestWorkspaces(t *testing.T) { _, err := server.Client.Workspace(context.Background(), "", workspace.Name) require.NoError(t, err) }) - - t.Run("AllHistory", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - history, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) - require.NoError(t, err) - require.Len(t, history, 0) - projectVersion := setupProjectVersion(t, server.Client, user, project) - _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectVersion.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - history, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) - require.NoError(t, err) - require.Len(t, history, 1) - }) - - t.Run("LatestHistory", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - _, err := server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) - require.Error(t, err) - projectVersion := setupProjectVersion(t, server.Client, user, project) - _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectVersion.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) - require.NoError(t, err) - }) - - t.Run("CreateHistory", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectVersion(t, server.Client, user, project) - - _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectHistory.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - }) - - t.Run("CreateHistoryAlreadyInProgress", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectVersion(t, server.Client, user, project) - - _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectHistory.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.NoError(t, err) - - _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectHistory.ID, - Transition: database.WorkspaceTransitionCreate, - }) - require.Error(t, err) - }) - - t.Run("CreateHistoryInvalidProjectVersion", func(t *testing.T) { - t.Parallel() - server := coderdtest.New(t) - user := server.RandomInitialUser(t) - _, workspace := setupProjectAndWorkspace(t, server.Client, user) - - _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: uuid.New(), - Transition: database.WorkspaceTransitionCreate, - }) - require.Error(t, err) - }) } diff --git a/codersdk/projects.go b/codersdk/projects.go index a075ebee084db..4b3a4e90e15d6 100644 --- a/codersdk/projects.go +++ b/codersdk/projects.go @@ -72,7 +72,7 @@ func (c *Client) ProjectHistory(ctx context.Context, organization, project strin } // CreateProjectHistory inserts a new version for the project. -func (c *Client) CreateProjectHistory(ctx context.Context, organization, project string, request coderd.CreateProjectVersionRequest) (coderd.ProjectHistory, error) { +func (c *Client) CreateProjectHistory(ctx context.Context, organization, project string, request coderd.CreateProjectHistoryRequest) (coderd.ProjectHistory, error) { res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/projects/%s/%s/history", organization, project), request) if err != nil { return coderd.ProjectHistory{}, err diff --git a/codersdk/projects_test.go b/codersdk/projects_test.go index acff520cb8c56..ad61d79110288 100644 --- a/codersdk/projects_test.go +++ b/codersdk/projects_test.go @@ -94,7 +94,7 @@ func TestProjects(t *testing.T) { t.Run("CreateVersionUnauthenticated", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) - _, err := server.Client.CreateProjectHistory(context.Background(), "org", "project", coderd.CreateProjectVersionRequest{ + _, err := server.Client.CreateProjectHistory(context.Background(), "org", "project", coderd.CreateProjectHistoryRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: []byte{}, }) @@ -119,7 +119,7 @@ func TestProjects(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.CreateProjectVersionRequest{ + _, err = server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: buffer.Bytes(), }) diff --git a/go.mod b/go.mod index cfe961dbd1544..527d1579b7759 100644 --- a/go.mod +++ b/go.mod @@ -35,10 +35,8 @@ require ( go.uber.org/goleak v1.1.12 golang.org/x/crypto v0.0.0-20220126234351-aa10faf2a1f8 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 google.golang.org/protobuf v1.27.1 - nhooyr.io/websocket v1.8.7 storj.io/drpc v0.0.29 ) @@ -108,6 +106,7 @@ require ( github.com/zeebo/errs v1.2.2 // indirect go.opencensus.io v0.23.0 // indirect golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect @@ -116,4 +115,5 @@ require ( google.golang.org/grpc v1.44.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + nhooyr.io/websocket v1.8.7 // indirect )