From ea690169b6f757d7de4e0638a1fb838ddef9bc90 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 18:19:50 +0000 Subject: [PATCH 01/11] 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 7a9f7149de88eb7a556d2114311e08022c5cc26a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 19:51:40 +0000 Subject: [PATCH 02/11] 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 | 11 +- coderd/coderd.go | 57 +++++---- coderd/projecthistory.go | 118 ++++++++++++++++++ coderd/projecthistory_test.go | 99 +++++++++++++++ coderd/projects.go | 126 ++++--------------- coderd/projects_test.go | 85 ------------- coderd/users.go | 36 +++--- coderd/workspacehistory.go | 182 +++++++++++++++++++++++++++ coderd/workspacehistory_test.go | 133 ++++++++++++++++++++ coderd/workspaces.go | 215 ++------------------------------ coderd/workspaces_test.go | 109 +--------------- codersdk/projects.go | 2 +- codersdk/projects_test.go | 4 +- go.mod | 4 +- 14 files changed, 627 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..2bc258de1779c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,14 @@ "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..f1a3e21db7f5a --- /dev/null +++ b/coderd/projecthistory_test.go @@ -0,0 +1,99 @@ +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.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..e8402b93a526c --- /dev/null +++ b/coderd/workspacehistory_test.go @@ -0,0 +1,133 @@ +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) { + 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 ) From 33ecab0c4c0b2879bfacab588f0671eb7d59d4e1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 20:04:34 +0000 Subject: [PATCH 03/11] Add provisioner daemon routes --- .vscode/settings.json | 16 +- coderd/coderd.go | 5 + coderd/coderdtest/coderdtest.go | 39 ++ coderd/coderdtest/coderdtest_test.go | 1 + coderd/projecthistory.go | 4 + coderd/provisionerdaemons.go | 610 +++++++++++++++++++++++++++ coderd/provisionerdaemons_test.go | 26 ++ coderd/workspacehistory.go | 4 + codersdk/provisioners.go | 50 +++ go.mod | 2 +- provisionerd/provisionerd.go | 8 +- 11 files changed, 761 insertions(+), 4 deletions(-) create mode 100644 coderd/provisionerdaemons.go create mode 100644 coderd/provisionerdaemons_test.go create mode 100644 codersdk/provisioners.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 2bc258de1779c..80b04ed4ebf95 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,9 +27,23 @@ "coderd", "coderdtest", "codersdk", + "drpc", + "drpcmux", + "drpcserver", + "goleak", + "hashicorp", "httpmw", + "moby", + "nhooyr", + "nosec", "oneof", + "protobuf", + "provisionerd", + "provisionersdk", + "retrier", + "sdkproto", "stretchr", - "xerrors" + "xerrors", + "yamux" ] } diff --git a/coderd/coderd.go b/coderd/coderd.go index 05569b8aeb5e1..8f8bd69b13fd9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -89,6 +89,11 @@ func New(options *Options) http.Handler { }) }) }) + + r.Route("/provisioners/daemons", func(r chi.Router) { + r.Get("/", api.provisionerDaemons) + r.Get("/serve", api.provisionerDaemonsServe) + }) }) r.NotFound(site.Handler().ServeHTTP) return r diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 1ecf069bce864..51cc23c5f0652 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -3,13 +3,16 @@ 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" @@ -17,6 +20,10 @@ import ( "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. @@ -57,6 +64,38 @@ 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, + }, + }) + require.NoError(t, err) + }() + + closer := provisionerd.New(s.Client.ProvisionerDaemonClient, &provisionerd.Options{ + Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelInfo), + PollInterval: 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 { diff --git a/coderd/coderdtest/coderdtest_test.go b/coderd/coderdtest/coderdtest_test.go index e36d1c1408cd1..b7312f96864fc 100644 --- a/coderd/coderdtest/coderdtest_test.go +++ b/coderd/coderdtest/coderdtest_test.go @@ -16,4 +16,5 @@ func TestNew(t *testing.T) { t.Parallel() server := coderdtest.New(t) _ = server.RandomInitialUser(t) + _ = server.AddProvisionerd(t) } diff --git a/coderd/projecthistory.go b/coderd/projecthistory.go index a5057b8c514f0..7e8dbd74c3c5c 100644 --- a/coderd/projecthistory.go +++ b/coderd/projecthistory.go @@ -116,3 +116,7 @@ func convertProjectHistory(history database.ProjectHistory) ProjectHistory { Name: history.Name, } } + +func projectHistoryLogsChannel(projectHistoryID uuid.UUID) string { + return fmt.Sprintf("project-history-logs:%s", projectHistoryID) +} diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go new file mode 100644 index 0000000000000..941a9a6ff599a --- /dev/null +++ b/coderd/provisionerdaemons.go @@ -0,0 +1,610 @@ +package coderd + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "time" + + "github.com/go-chi/render" + "github.com/google/uuid" + "github.com/hashicorp/yamux" + "github.com/moby/moby/pkg/namesgenerator" + "golang.org/x/xerrors" + "nhooyr.io/websocket" + "storj.io/drpc/drpcmux" + "storj.io/drpc/drpcserver" + + "github.com/coder/coder/coderd/projectparameter" + "github.com/coder/coder/database" + "github.com/coder/coder/httpapi" + "github.com/coder/coder/provisionerd/proto" + sdkproto "github.com/coder/coder/provisionersdk/proto" +) + +type ProvisionerDaemon database.ProvisionerDaemon + +// Lists all registered provisioner daemons. +func (api *api) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { + daemons, err := api.Database.GetProvisionerDaemons(r.Context()) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get provisioner daemons: %s", err), + }) + return + } + + render.Status(r, http.StatusOK) + render.JSON(rw, r, daemons) +} + +// Serves the provisioner daemon protobuf API over a WebSocket. +func (api *api) provisionerDaemonsServe(rw http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + + daemon, err := api.Database.InsertProvisionerDaemon(r.Context(), database.InsertProvisionerDaemonParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + Name: namesgenerator.GetRandomName(1), + Provisioners: []database.ProvisionerType{database.ProvisionerTypeCdrBasic, database.ProvisionerTypeTerraform}, + }) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, fmt.Sprintf("insert provisioner daemon:% s", err)) + return + } + + // Multiplexes the incoming connection using yamux. + // This allows multiple function calls to occur over + // the same connection. + session, err := yamux.Server(websocket.NetConn(r.Context(), conn, websocket.MessageBinary), nil) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, fmt.Sprintf("multiplex server: %s", err)) + return + } + mux := drpcmux.New() + err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdServer{ + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + }) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, fmt.Sprintf("drpc register provisioner daemon: %s", err)) + return + } + server := drpcserver.New(mux) + err = server.Serve(r.Context(), session) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, fmt.Sprintf("serve: %s", err)) + } +} + +// The input for a "workspace_provision" job. +type workspaceProvisionJob struct { + WorkspaceHistoryID uuid.UUID `json:"workspace_history_id"` +} + +// The input for a "project_import" job. +type projectImportJob struct { + ProjectHistoryID uuid.UUID `json:"project_history_id"` +} + +// Implementation of the provisioner daemon protobuf server. +type provisionerdServer struct { + ID uuid.UUID + Database database.Store + Pubsub database.Pubsub +} + +// AcquireJob queries the database to lock a job. +func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) { + // This marks the job as locked in the database. + job, err := server.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + StartedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + WorkerID: uuid.NullUUID{ + UUID: server.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + }) + if errors.Is(err, sql.ErrNoRows) { + // The provisioner daemon assumes no jobs are available if + // an empty struct is returned. + return &proto.AcquiredJob{}, nil + } + if err != nil { + return nil, xerrors.Errorf("acquire job: %w", err) + } + // Marks the acquired job as failed with the error message provided. + failJob := func(errorMessage string) error { + err = server.Database.UpdateProvisionerJobByID(ctx, database.UpdateProvisionerJobByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + Error: sql.NullString{ + String: errorMessage, + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update provisioner job: %w", err) + } + return xerrors.Errorf("request job was invalidated: %s", errorMessage) + } + + project, err := server.Database.GetProjectByID(ctx, job.ProjectID) + if err != nil { + return nil, failJob(fmt.Sprintf("get project: %s", err)) + } + organization, err := server.Database.GetOrganizationByID(ctx, project.OrganizationID) + if err != nil { + return nil, failJob(fmt.Sprintf("get organization: %s", err)) + } + user, err := server.Database.GetUserByID(ctx, job.InitiatorID) + if err != nil { + return nil, failJob(fmt.Sprintf("get user: %s", err)) + } + + protoJob := &proto.AcquiredJob{ + JobId: job.ID.String(), + CreatedAt: job.CreatedAt.UnixMilli(), + Provisioner: string(job.Provisioner), + OrganizationName: organization.Name, + ProjectName: project.Name, + UserName: user.Username, + } + var projectHistory database.ProjectHistory + switch job.Type { + case database.ProvisionerJobTypeWorkspaceProvision: + var input workspaceProvisionJob + err = json.Unmarshal(job.Input, &input) + if err != nil { + return nil, failJob(fmt.Sprintf("unmarshal job input %q: %s", job.Input, err)) + } + workspaceHistory, err := server.Database.GetWorkspaceHistoryByID(ctx, input.WorkspaceHistoryID) + if err != nil { + return nil, failJob(fmt.Sprintf("get workspace history: %s", err)) + } + workspace, err := server.Database.GetWorkspaceByID(ctx, workspaceHistory.WorkspaceID) + if err != nil { + return nil, failJob(fmt.Sprintf("get workspace: %s", err)) + } + projectHistory, err = server.Database.GetProjectHistoryByID(ctx, workspaceHistory.ProjectHistoryID) + if err != nil { + return nil, failJob(fmt.Sprintf("get project history: %s", err)) + } + + // Compute parameters for the workspace to consume. + parameters, err := projectparameter.Compute(ctx, server.Database, projectparameter.Scope{ + OrganizationID: organization.ID, + ProjectID: project.ID, + ProjectHistoryID: projectHistory.ID, + UserID: user.ID, + WorkspaceID: workspace.ID, + WorkspaceHistoryID: workspaceHistory.ID, + }) + if err != nil { + return nil, failJob(fmt.Sprintf("compute parameters: %s", err)) + } + // Convert parameters to the protobuf type. + protoParameters := make([]*sdkproto.ParameterValue, 0, len(parameters)) + for _, parameter := range parameters { + protoParameters = append(protoParameters, parameter.Proto) + } + + provisionerState := []byte{} + // If workspace history exists before this entry, use that state. + // We can't use the before state everytime, because if a job fails + // for some random reason, the workspace shouldn't be reset. + // + // Maybe we should make state global on a workspace? + if workspaceHistory.BeforeID.Valid { + beforeHistory, err := server.Database.GetWorkspaceHistoryByID(ctx, workspaceHistory.BeforeID.UUID) + if err != nil { + return nil, failJob(fmt.Sprintf("get workspace history: %s", err)) + } + provisionerState = beforeHistory.ProvisionerState + } + + protoJob.Type = &proto.AcquiredJob_WorkspaceProvision_{ + WorkspaceProvision: &proto.AcquiredJob_WorkspaceProvision{ + WorkspaceHistoryId: workspaceHistory.ID.String(), + WorkspaceName: workspace.Name, + State: provisionerState, + ParameterValues: protoParameters, + }, + } + case database.ProvisionerJobTypeProjectImport: + var input projectImportJob + err = json.Unmarshal(job.Input, &input) + if err != nil { + return nil, failJob(fmt.Sprintf("unmarshal job input %q: %s", job.Input, err)) + } + projectHistory, err = server.Database.GetProjectHistoryByID(ctx, input.ProjectHistoryID) + if err != nil { + return nil, failJob(fmt.Sprintf("get project history: %s", err)) + } + } + switch projectHistory.StorageMethod { + case database.ProjectStorageMethodInlineArchive: + protoJob.ProjectSourceArchive = projectHistory.StorageSource + default: + return nil, failJob(fmt.Sprintf("unsupported storage source: %q", projectHistory.StorageMethod)) + } + + return protoJob, err +} + +func (server *provisionerdServer) UpdateJob(stream proto.DRPCProvisionerDaemon_UpdateJobStream) error { + for { + update, err := stream.Recv() + if err != nil { + return err + } + parsedID, err := uuid.Parse(update.JobId) + if err != nil { + return xerrors.Errorf("parse job id: %w", err) + } + job, err := server.Database.GetProvisionerJobByID(stream.Context(), parsedID) + if err != nil { + return xerrors.Errorf("get job: %w", err) + } + if !job.WorkerID.Valid { + return errors.New("job isn't running yet") + } + if job.WorkerID.UUID.String() != server.ID.String() { + return errors.New("you don't own this job") + } + + err = server.Database.UpdateProvisionerJobByID(stream.Context(), database.UpdateProvisionerJobByIDParams{ + ID: parsedID, + UpdatedAt: database.Now(), + }) + if err != nil { + return xerrors.Errorf("update job: %w", err) + } + switch job.Type { + case database.ProvisionerJobTypeProjectImport: + if len(update.ProjectImportLogs) == 0 { + continue + } + var input projectImportJob + err = json.Unmarshal(job.Input, &input) + if err != nil { + return xerrors.Errorf("unmarshal job input %q: %s", job.Input, err) + } + insertParams := database.InsertProjectHistoryLogsParams{ + ProjectHistoryID: input.ProjectHistoryID, + } + for _, log := range update.ProjectImportLogs { + logLevel, err := convertLogLevel(log.Level) + if err != nil { + return xerrors.Errorf("convert log level: %w", err) + } + logSource, err := convertLogSource(log.Source) + if err != nil { + return xerrors.Errorf("convert log source: %w", err) + } + insertParams.ID = append(insertParams.ID, uuid.New()) + insertParams.CreatedAt = append(insertParams.CreatedAt, time.UnixMilli(log.CreatedAt)) + insertParams.Level = append(insertParams.Level, logLevel) + insertParams.Source = append(insertParams.Source, logSource) + insertParams.Output = append(insertParams.Output, log.Output) + } + logs, err := server.Database.InsertProjectHistoryLogs(stream.Context(), insertParams) + if err != nil { + return xerrors.Errorf("insert project logs: %w", err) + } + data, err := json.Marshal(logs) + if err != nil { + return xerrors.Errorf("marshal project log: %w", err) + } + err = server.Pubsub.Publish(projectHistoryLogsChannel(input.ProjectHistoryID), data) + if err != nil { + return xerrors.Errorf("publish history log: %w", err) + } + case database.ProvisionerJobTypeWorkspaceProvision: + if len(update.WorkspaceProvisionLogs) == 0 { + continue + } + var input workspaceProvisionJob + err = json.Unmarshal(job.Input, &input) + if err != nil { + return xerrors.Errorf("unmarshal job input %q: %s", job.Input, err) + } + insertParams := database.InsertWorkspaceHistoryLogsParams{ + WorkspaceHistoryID: input.WorkspaceHistoryID, + } + for _, log := range update.WorkspaceProvisionLogs { + logLevel, err := convertLogLevel(log.Level) + if err != nil { + return xerrors.Errorf("convert log level: %w", err) + } + logSource, err := convertLogSource(log.Source) + if err != nil { + return xerrors.Errorf("convert log source: %w", err) + } + insertParams.ID = append(insertParams.ID, uuid.New()) + insertParams.CreatedAt = append(insertParams.CreatedAt, time.UnixMilli(log.CreatedAt)) + insertParams.Level = append(insertParams.Level, logLevel) + insertParams.Source = append(insertParams.Source, logSource) + insertParams.Output = append(insertParams.Output, log.Output) + } + logs, err := server.Database.InsertWorkspaceHistoryLogs(stream.Context(), insertParams) + if err != nil { + return xerrors.Errorf("insert workspace logs: %w", err) + } + data, err := json.Marshal(logs) + if err != nil { + return xerrors.Errorf("marshal project log: %w", err) + } + err = server.Pubsub.Publish(workspaceHistoryLogsChannel(input.WorkspaceHistoryID), data) + if err != nil { + return xerrors.Errorf("publish history log: %w", err) + } + } + } +} + +func (server *provisionerdServer) CancelJob(ctx context.Context, cancelJob *proto.CancelledJob) (*proto.Empty, error) { + jobID, err := uuid.Parse(cancelJob.JobId) + if err != nil { + return nil, xerrors.Errorf("parse job id: %w", err) + } + err = server.Database.UpdateProvisionerJobByID(ctx, database.UpdateProvisionerJobByIDParams{ + ID: jobID, + CancelledAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + UpdatedAt: database.Now(), + Error: sql.NullString{ + String: cancelJob.Error, + Valid: cancelJob.Error != "", + }, + }) + if err != nil { + return nil, xerrors.Errorf("update provisioner job: %w", err) + } + return &proto.Empty{}, nil +} + +// CompleteJob is triggered by a provision daemon to mark a provisioner job as completed. +func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) { + jobID, err := uuid.Parse(completed.JobId) + if err != nil { + return nil, xerrors.Errorf("parse job id: %w", err) + } + job, err := server.Database.GetProvisionerJobByID(ctx, jobID) + if err != nil { + return nil, xerrors.Errorf("get job by id: %w", err) + } + // TODO: Check if the worker ID matches! + // If it doesn't, a provisioner daemon could be impersonating another job! + + switch jobType := completed.Type.(type) { + case *proto.CompletedJob_ProjectImport_: + var input projectImportJob + err = json.Unmarshal(job.Input, &input) + if err != nil { + return nil, xerrors.Errorf("unmarshal job data: %w", err) + } + + // Validate that all parameters send from the provisioner daemon + // follow the protocol. + projectParameters := make([]database.InsertProjectParameterParams, 0, len(jobType.ProjectImport.ParameterSchemas)) + for _, protoParameter := range jobType.ProjectImport.ParameterSchemas { + validationTypeSystem, err := convertValidationTypeSystem(protoParameter.ValidationTypeSystem) + if err != nil { + return nil, xerrors.Errorf("convert validation type system for %q: %w", protoParameter.Name, err) + } + + projectParameter := database.InsertProjectParameterParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + ProjectHistoryID: input.ProjectHistoryID, + Name: protoParameter.Name, + Description: protoParameter.Description, + RedisplayValue: protoParameter.RedisplayValue, + ValidationError: protoParameter.ValidationError, + ValidationCondition: protoParameter.ValidationCondition, + ValidationValueType: protoParameter.ValidationValueType, + ValidationTypeSystem: validationTypeSystem, + + AllowOverrideDestination: protoParameter.AllowOverrideDestination, + AllowOverrideSource: protoParameter.AllowOverrideSource, + } + + // It's possible a parameter doesn't define a default source! + if protoParameter.DefaultSource != nil { + parameterSourceScheme, err := convertParameterSourceScheme(protoParameter.DefaultSource.Scheme) + if err != nil { + return nil, xerrors.Errorf("convert parameter source scheme: %w", err) + } + projectParameter.DefaultSourceScheme = parameterSourceScheme + projectParameter.DefaultSourceValue = sql.NullString{ + String: protoParameter.DefaultSource.Value, + Valid: protoParameter.DefaultSource.Value != "", + } + } + + // It's possible a parameter doesn't define a default destination! + if protoParameter.DefaultDestination != nil { + parameterDestinationScheme, err := convertParameterDestinationScheme(protoParameter.DefaultDestination.Scheme) + if err != nil { + return nil, xerrors.Errorf("convert parameter destination scheme: %w", err) + } + projectParameter.DefaultDestinationScheme = parameterDestinationScheme + projectParameter.DefaultDestinationValue = sql.NullString{ + String: protoParameter.DefaultDestination.Value, + Valid: protoParameter.DefaultDestination.Value != "", + } + } + + projectParameters = append(projectParameters, projectParameter) + } + + // This must occur in a transaction in case of failure. + err = server.Database.InTx(func(db database.Store) error { + err = db.UpdateProvisionerJobByID(ctx, database.UpdateProvisionerJobByIDParams{ + ID: jobID, + UpdatedAt: database.Now(), + CompletedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update provisioner job: %w", err) + } + // This could be a bulk-insert operation to improve performance. + // See the "InsertWorkspaceHistoryLogs" query. + for _, projectParameter := range projectParameters { + _, err = db.InsertProjectParameter(ctx, projectParameter) + if err != nil { + return xerrors.Errorf("insert project parameter %q: %w", projectParameter.Name, err) + } + } + return nil + }) + if err != nil { + return nil, xerrors.Errorf("complete job: %w", err) + } + case *proto.CompletedJob_WorkspaceProvision_: + var input workspaceProvisionJob + err = json.Unmarshal(job.Input, &input) + if err != nil { + return nil, xerrors.Errorf("unmarshal job data: %w", err) + } + + workspaceHistory, err := server.Database.GetWorkspaceHistoryByID(ctx, input.WorkspaceHistoryID) + if err != nil { + return nil, xerrors.Errorf("get workspace history: %w", err) + } + + err = server.Database.InTx(func(db database.Store) error { + err = db.UpdateProvisionerJobByID(ctx, database.UpdateProvisionerJobByIDParams{ + ID: jobID, + UpdatedAt: database.Now(), + CompletedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update provisioner job: %w", err) + } + err = db.UpdateWorkspaceHistoryByID(ctx, database.UpdateWorkspaceHistoryByIDParams{ + ID: workspaceHistory.ID, + UpdatedAt: database.Now(), + ProvisionerState: jobType.WorkspaceProvision.State, + CompletedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + }) + if err != nil { + return xerrors.Errorf("update workspace history: %w", err) + } + // This could be a bulk insert to improve performance. + for _, protoResource := range jobType.WorkspaceProvision.Resources { + _, err = db.InsertWorkspaceResource(ctx, database.InsertWorkspaceResourceParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + WorkspaceHistoryID: input.WorkspaceHistoryID, + Type: protoResource.Type, + Name: protoResource.Name, + // TODO: Generate this at the variable validation phase. + // Set the value in `default_source`, and disallow overwrite. + WorkspaceAgentToken: uuid.NewString(), + }) + if err != nil { + return xerrors.Errorf("insert workspace resource %q: %w", protoResource.Name, err) + } + } + return nil + }) + if err != nil { + return nil, xerrors.Errorf("complete job: %w", err) + } + default: + return nil, xerrors.Errorf("unknown job type %q; ensure coderd and provisionerd versions match", + reflect.TypeOf(completed.Type).String()) + } + + return &proto.Empty{}, nil +} + +func convertValidationTypeSystem(typeSystem sdkproto.ParameterSchema_TypeSystem) (database.ParameterTypeSystem, error) { + switch typeSystem { + case sdkproto.ParameterSchema_HCL: + return database.ParameterTypeSystemHCL, nil + default: + return database.ParameterTypeSystem(""), xerrors.Errorf("unknown type system: %d", typeSystem) + } +} + +func convertParameterSourceScheme(sourceScheme sdkproto.ParameterSource_Scheme) (database.ParameterSourceScheme, error) { + switch sourceScheme { + case sdkproto.ParameterSource_DATA: + return database.ParameterSourceSchemeData, nil + default: + return database.ParameterSourceScheme(""), xerrors.Errorf("unknown parameter source scheme: %d", sourceScheme) + } +} + +func convertParameterDestinationScheme(destinationScheme sdkproto.ParameterDestination_Scheme) (database.ParameterDestinationScheme, error) { + switch destinationScheme { + case sdkproto.ParameterDestination_ENVIRONMENT_VARIABLE: + return database.ParameterDestinationSchemeEnvironmentVariable, nil + case sdkproto.ParameterDestination_PROVISIONER_VARIABLE: + return database.ParameterDestinationSchemeProvisionerVariable, nil + default: + return database.ParameterDestinationScheme(""), xerrors.Errorf("unknown parameter destination scheme: %d", destinationScheme) + } +} + +func convertLogLevel(logLevel sdkproto.LogLevel) (database.LogLevel, error) { + switch logLevel { + case sdkproto.LogLevel_TRACE: + return database.LogLevelTrace, nil + case sdkproto.LogLevel_DEBUG: + return database.LogLevelDebug, nil + case sdkproto.LogLevel_INFO: + return database.LogLevelInfo, nil + case sdkproto.LogLevel_WARN: + return database.LogLevelWarn, nil + case sdkproto.LogLevel_ERROR: + return database.LogLevelError, nil + default: + return database.LogLevel(""), xerrors.Errorf("unknown log level: %d", logLevel) + } +} + +func convertLogSource(logSource proto.LogSource) (database.LogSource, error) { + switch logSource { + case proto.LogSource_PROVISIONER_DAEMON: + return database.LogSourceProvisionerDaemon, nil + case proto.LogSource_PROVISIONER: + return database.LogSourceProvisioner, nil + default: + return database.LogSource(""), xerrors.Errorf("unknown log source: %d", logSource) + } +} diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go new file mode 100644 index 0000000000000..5cba701d5a34e --- /dev/null +++ b/coderd/provisionerdaemons_test.go @@ -0,0 +1,26 @@ +package coderd_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" +) + +func TestProvisionerDaemons(t *testing.T) { + t.Parallel() + + t.Run("Register", func(t *testing.T) { + t.Parallel() + server := coderdtest.New(t) + _ = server.AddProvisionerd(t) + require.Eventually(t, func() bool { + daemons, err := server.Client.ProvisionerDaemons(context.Background()) + require.NoError(t, err) + return len(daemons) > 0 + }, time.Second, 10*time.Millisecond) + }) +} diff --git a/coderd/workspacehistory.go b/coderd/workspacehistory.go index 32eba2e98e2da..fb409f6b5a8b3 100644 --- a/coderd/workspacehistory.go +++ b/coderd/workspacehistory.go @@ -180,3 +180,7 @@ func (api *api) latestWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Req render.Status(r, http.StatusOK) render.JSON(rw, r, convertWorkspaceHistory(history)) } + +func workspaceHistoryLogsChannel(workspaceHistoryID uuid.UUID) string { + return fmt.Sprintf("workspace-history-logs:%s", workspaceHistoryID) +} diff --git a/codersdk/provisioners.go b/codersdk/provisioners.go new file mode 100644 index 0000000000000..cfc908a7d39b3 --- /dev/null +++ b/codersdk/provisioners.go @@ -0,0 +1,50 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/hashicorp/yamux" + "golang.org/x/xerrors" + "nhooyr.io/websocket" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/provisionerd/proto" + "github.com/coder/coder/provisionersdk" +) + +func (c *Client) ProvisionerDaemons(ctx context.Context) ([]coderd.ProvisionerDaemon, error) { + res, err := c.request(ctx, http.MethodGet, "/api/v2/provisioners/daemons", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var daemons []coderd.ProvisionerDaemon + return daemons, json.NewDecoder(res.Body).Decode(&daemons) +} + +// ProvisionerDaemonClient returns the gRPC service for a provisioner daemon implementation. +func (c *Client) ProvisionerDaemonClient(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { + serverURL, err := c.url.Parse("/api/v2/provisioners/daemons/serve") + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: c.httpClient, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + session, err := yamux.Client(websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil) + if err != nil { + return nil, xerrors.Errorf("multiplex client: %w", err) + } + return proto.NewDRPCProvisionerDaemonClient(provisionersdk.Conn(session)), nil +} diff --git a/go.mod b/go.mod index 527d1579b7759..0143840304b6f 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 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 ) @@ -115,5 +116,4 @@ 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 ) diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 3fe7ce793c65e..89d175d3688d6 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -87,7 +87,7 @@ type provisionerDaemon struct { acquiredJobDone chan struct{} } -// Connnect establishes a connection to coderd. +// Connect establishes a connection to coderd. func (p *provisionerDaemon) connect(ctx context.Context) { p.connectMutex.Lock() defer p.connectMutex.Unlock() @@ -98,7 +98,9 @@ func (p *provisionerDaemon) connect(ctx context.Context) { for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { p.client, err = p.clientDialer(ctx) if err != nil { - // Warn + if errors.Is(err, context.Canceled) { + return + } p.opts.Logger.Warn(context.Background(), "failed to dial", slog.Error(err)) continue } @@ -496,6 +498,8 @@ func (p *provisionerDaemon) closeWithError(err error) error { close(p.closed) p.closeCancel() + p.connectMutex.Lock() + defer p.connectMutex.Unlock() if p.updateStream != nil { _ = p.client.DRPCConn().Close() _ = p.updateStream.Close() From 79875964d08f3d0ca8cb3c72f397354663cf2a1e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 23:13:31 +0000 Subject: [PATCH 04/11] Add periodic updates --- provisionerd/provisionerd.go | 28 +++++++++++++++-- provisionerd/provisionerd_test.go | 51 ++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 89d175d3688d6..38cc8cfd34082 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -32,9 +32,10 @@ type Provisioners map[string]sdkproto.DRPCProvisionerClient type Options struct { Logger slog.Logger - PollInterval time.Duration - Provisioners Provisioners - WorkDirectory string + UpdateInterval time.Duration + PollInterval time.Duration + Provisioners Provisioners + WorkDirectory string } // New creates and starts a provisioner daemon. @@ -42,6 +43,9 @@ func New(clientDialer Dialer, opts *Options) io.Closer { if opts.PollInterval == 0 { opts.PollInterval = 5 * time.Second } + if opts.UpdateInterval == 0 { + opts.UpdateInterval = 5 * time.Second + } ctx, ctxCancel := context.WithCancel(context.Background()) daemon := &provisionerDaemon{ clientDialer: clientDialer, @@ -192,6 +196,24 @@ func (p *provisionerDaemon) isRunningJob() bool { } func (p *provisionerDaemon) runJob(ctx context.Context) { + go func() { + ticker := time.NewTicker(p.opts.UpdateInterval) + defer ticker.Stop() + select { + case <-p.closed: + return + case <-ctx.Done(): + return + case <-ticker.C: + err := p.updateStream.Send(&proto.JobUpdate{ + JobId: p.acquiredJob.JobId, + }) + if err != nil { + p.cancelActiveJob(fmt.Sprintf("send periodic update: %s", err)) + return + } + } + }() go func() { select { case <-p.closed: diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index 8148c5369d938..376bfd1eaadb1 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -153,6 +153,48 @@ func TestProvisionerd(t *testing.T) { require.NoError(t, closer.Close()) }) + t.Run("RunningPeriodicUpdate", func(t *testing.T) { + t.Parallel() + completeChan := make(chan struct{}) + closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { + return createProvisionerDaemonClient(t, provisionerDaemonTestServer{ + acquireJob: func(ctx context.Context, _ *proto.Empty) (*proto.AcquiredJob, error) { + return &proto.AcquiredJob{ + JobId: "test", + Provisioner: "someprovisioner", + ProjectSourceArchive: createTar(t, map[string]string{ + "test.txt": "content", + }), + Type: &proto.AcquiredJob_ProjectImport_{ + ProjectImport: &proto.AcquiredJob_ProjectImport{}, + }, + }, nil + }, + updateJob: func(stream proto.DRPCProvisionerDaemon_UpdateJobStream) error { + for { + _, err := stream.Recv() + if err != nil { + return err + } + close(completeChan) + } + }, + cancelJob: func(ctx context.Context, job *proto.CancelledJob) (*proto.Empty, error) { + return &proto.Empty{}, nil + }, + }), nil + }, provisionerd.Provisioners{ + "someprovisioner": createProvisionerClient(t, provisionerTestServer{ + parse: func(request *sdkproto.Parse_Request, stream sdkproto.DRPCProvisioner_ParseStream) error { + <-stream.Context().Done() + return nil + }, + }), + }) + <-completeChan + require.NoError(t, closer.Close()) + }) + t.Run("ProjectImport", func(t *testing.T) { t.Parallel() var ( @@ -331,10 +373,11 @@ func createTar(t *testing.T, files map[string]string) []byte { // Creates a provisionerd implementation with the provided dialer and provisioners. func createProvisionerd(t *testing.T, dialer provisionerd.Dialer, provisioners provisionerd.Provisioners) io.Closer { closer := provisionerd.New(dialer, &provisionerd.Options{ - Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug), - PollInterval: 50 * time.Millisecond, - Provisioners: provisioners, - WorkDirectory: t.TempDir(), + Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug), + PollInterval: 50 * time.Millisecond, + UpdateInterval: 50 * time.Millisecond, + Provisioners: provisioners, + WorkDirectory: t.TempDir(), }) t.Cleanup(func() { _ = closer.Close() From bc27864ee38b07980e30412ba5b0bb4bf550f29b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 23:13:47 +0000 Subject: [PATCH 05/11] Skip pubsub if short --- database/pubsub_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/database/pubsub_test.go b/database/pubsub_test.go index fb21383d7f24c..55f34896184c6 100644 --- a/database/pubsub_test.go +++ b/database/pubsub_test.go @@ -17,6 +17,11 @@ import ( func TestPubsub(t *testing.T) { t.Parallel() + if testing.Short() { + t.Skip() + return + } + t.Run("Postgres", func(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) From d062b5b6141ab5fbb45a0451b450a48f9e740a78 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 1 Feb 2022 23:50:25 +0000 Subject: [PATCH 06/11] Return jobs with WorkspaceHistory --- .vscode/settings.json | 2 + coderd/provisionerdaemons.go | 4 - coderd/provisioners.go | 67 ++++++++++++++ coderd/workspacehistory.go | 91 +++++++++++++++++--- coderd/workspaces.go | 17 ---- database/databasefake/databasefake.go | 1 - database/dump.sql | 1 - database/migrations/000003_workspaces.up.sql | 1 - database/models.go | 1 - database/query.sql | 5 +- database/query.sql.go | 22 ++--- go.mod | 2 - 12 files changed, 156 insertions(+), 58 deletions(-) create mode 100644 coderd/provisioners.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 80b04ed4ebf95..f23e833a2c98d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,6 +35,7 @@ "httpmw", "moby", "nhooyr", + "nolint", "nosec", "oneof", "protobuf", @@ -43,6 +44,7 @@ "retrier", "sdkproto", "stretchr", + "unconvert", "xerrors", "yamux" ] diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 941a9a6ff599a..b6d42c423240c 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -515,10 +515,6 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr ID: workspaceHistory.ID, UpdatedAt: database.Now(), ProvisionerState: jobType.WorkspaceProvision.State, - CompletedAt: sql.NullTime{ - Time: database.Now(), - Valid: true, - }, }) if err != nil { return xerrors.Errorf("update workspace history: %w", err) diff --git a/coderd/provisioners.go b/coderd/provisioners.go new file mode 100644 index 0000000000000..f1eb3cf8f0fc2 --- /dev/null +++ b/coderd/provisioners.go @@ -0,0 +1,67 @@ +package coderd + +import ( + "time" + + "github.com/google/uuid" + + "github.com/coder/coder/database" +) + +type ProvisionerJobStatus string + +const ( + ProvisionerJobStatusPending ProvisionerJobStatus = "pending" + ProvisionerJobStatusRunning ProvisionerJobStatus = "running" + ProvisionerJobStatusSucceeded ProvisionerJobStatus = "succeeded" + ProvisionerJobStatusFailed ProvisionerJobStatus = "failed" +) + +type ProvisionerJob struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CancelledAt *time.Time `json:"canceled_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Status ProvisionerJobStatus `json:"status"` + Error string `json:"error,omitempty"` + Provisioner database.ProvisionerType `json:"provisioner"` + WorkerID *uuid.UUID `json:"worker_id,omitempty"` +} + +func convertProvisionerJob(provisionerJob database.ProvisionerJob) ProvisionerJob { + job := ProvisionerJob{ + CreatedAt: provisionerJob.CreatedAt, + UpdatedAt: provisionerJob.UpdatedAt, + Error: provisionerJob.Error.String, + Provisioner: provisionerJob.Provisioner, + } + if provisionerJob.StartedAt.Valid { + job.StartedAt = &provisionerJob.StartedAt.Time + } + if provisionerJob.CancelledAt.Valid { + job.CancelledAt = &provisionerJob.CancelledAt.Time + } + if provisionerJob.CompletedAt.Valid { + job.CompletedAt = &provisionerJob.CompletedAt.Time + } + if provisionerJob.WorkerID.Valid { + job.WorkerID = &provisionerJob.WorkerID.UUID + } + + switch { + case provisionerJob.CancelledAt.Valid: + job.Status = ProvisionerJobStatusFailed + case !provisionerJob.StartedAt.Valid: + job.Status = ProvisionerJobStatusPending + case provisionerJob.CompletedAt.Valid: + job.Status = ProvisionerJobStatusSucceeded + case database.Now().Sub(provisionerJob.UpdatedAt) > 30*time.Second: + job.Status = ProvisionerJobStatusFailed + job.Error = "Worker failed to update job in time." + default: + job.Status = ProvisionerJobStatusRunning + } + + return job +} diff --git a/coderd/workspacehistory.go b/coderd/workspacehistory.go index fb409f6b5a8b3..624aa73756efb 100644 --- a/coderd/workspacehistory.go +++ b/coderd/workspacehistory.go @@ -2,6 +2,7 @@ package coderd import ( "database/sql" + "encoding/json" "errors" "fmt" "net/http" @@ -22,13 +23,13 @@ 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"` + Job ProvisionerJob `json:"job"` } // CreateWorkspaceHistoryRequest provides options to update the latest workspace history. @@ -37,8 +38,6 @@ type CreateWorkspaceHistoryRequest struct { 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) { @@ -63,16 +62,28 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque }) return } + project, err := api.Database.GetProjectByID(r.Context(), projectHistory.ProjectID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get project: %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 + priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.ProvisionJobID) + if err == nil { + convertedJob := convertProvisionerJob(priorJob) + if convertedJob.Status == ProvisionerJobStatusPending || + convertedJob.Status == ProvisionerJobStatusRunning { + httpapi.Write(rw, http.StatusConflict, httpapi.Response{ + Message: "a workspace build is already active", + }) + return + } } priorHistoryID = uuid.NullUUID{ @@ -87,10 +98,34 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque return } + var provisionerJob database.ProvisionerJob 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 { + // Generate the ID before-hand so the provisioner job is aware of it! + workspaceHistoryID := uuid.New() + input, err := json.Marshal(workspaceProvisionJob{ + WorkspaceHistoryID: workspaceHistoryID, + }) + if err != nil { + return xerrors.Errorf("marshal provision job: %w", err) + } + + provisionerJob, err = db.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + InitiatorID: user.ID, + Provisioner: project.Provisioner, + Type: database.ProvisionerJobTypeWorkspaceProvision, + ProjectID: project.ID, + Input: input, + }) + if err != nil { + return xerrors.Errorf("insert provisioner job: %w", err) + } + workspaceHistory, err = db.InsertWorkspaceHistory(r.Context(), database.InsertWorkspaceHistoryParams{ ID: uuid.New(), CreatedAt: database.Now(), @@ -100,8 +135,7 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque BeforeID: priorHistoryID, Initiator: user.ID, Transition: createBuild.Transition, - // This should create a provision job once that gets implemented! - ProvisionJobID: uuid.New(), + ProvisionJobID: provisionerJob.ID, }) if err != nil { return xerrors.Errorf("insert workspace history: %w", err) @@ -132,7 +166,7 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque } render.Status(r, http.StatusCreated) - render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory)) + render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory, provisionerJob)) } // Returns all workspace history. This is not sorted. Use before/after to chronologically sort. @@ -152,7 +186,14 @@ func (api *api) workspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) apiHistory := make([]WorkspaceHistory, 0, len(histories)) for _, history := range histories { - apiHistory = append(apiHistory, convertWorkspaceHistory(history)) + job, err := api.Database.GetProvisionerJobByID(r.Context(), history.ProvisionJobID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get provisioner job: %s", err), + }) + return + } + apiHistory = append(apiHistory, convertWorkspaceHistory(history, job)) } render.Status(r, http.StatusOK) @@ -176,9 +217,33 @@ func (api *api) latestWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Req }) return } + job, err := api.Database.GetProvisionerJobByID(r.Context(), history.ProvisionJobID) + 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, convertWorkspaceHistory(history)) + render.JSON(rw, r, convertWorkspaceHistory(history, job)) +} + +// Converts the internal history representation to a public external-facing model. +func convertWorkspaceHistory(workspaceHistory database.WorkspaceHistory, provisionerJob database.ProvisionerJob) WorkspaceHistory { + //nolint:unconvert + return WorkspaceHistory(WorkspaceHistory{ + ID: workspaceHistory.ID, + CreatedAt: workspaceHistory.CreatedAt, + UpdatedAt: workspaceHistory.UpdatedAt, + WorkspaceID: workspaceHistory.WorkspaceID, + ProjectHistoryID: workspaceHistory.ProjectHistoryID, + BeforeID: workspaceHistory.BeforeID.UUID, + AfterID: workspaceHistory.AfterID.UUID, + Transition: workspaceHistory.Transition, + Initiator: workspaceHistory.Initiator, + Job: convertProvisionerJob(provisionerJob), + }) } func workspaceHistoryLogsChannel(workspaceHistoryID uuid.UUID) string { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 60504fb2cc184..01ef9870cecd4 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -149,20 +149,3 @@ func (*api) workspaceByUser(rw http.ResponseWriter, r *http.Request) { func convertWorkspace(workspace database.Workspace) Workspace { return Workspace(workspace) } - -// Converts the internal history representation to a public external-facing model. -func convertWorkspaceHistory(workspaceHistory database.WorkspaceHistory) WorkspaceHistory { - //nolint:unconvert - return WorkspaceHistory(WorkspaceHistory{ - ID: workspaceHistory.ID, - CreatedAt: workspaceHistory.CreatedAt, - UpdatedAt: workspaceHistory.UpdatedAt, - CompletedAt: workspaceHistory.CompletedAt.Time, - WorkspaceID: workspaceHistory.WorkspaceID, - ProjectHistoryID: workspaceHistory.ProjectHistoryID, - BeforeID: workspaceHistory.BeforeID.UUID, - AfterID: workspaceHistory.AfterID.UUID, - Transition: workspaceHistory.Transition, - Initiator: workspaceHistory.Initiator, - }) -} diff --git a/database/databasefake/databasefake.go b/database/databasefake/databasefake.go index 19b5b0a0d060e..24c674ea371e2 100644 --- a/database/databasefake/databasefake.go +++ b/database/databasefake/databasefake.go @@ -764,7 +764,6 @@ func (q *fakeQuerier) UpdateWorkspaceHistoryByID(_ context.Context, arg database continue } workspaceHistory.UpdatedAt = arg.UpdatedAt - workspaceHistory.CompletedAt = arg.CompletedAt workspaceHistory.AfterID = arg.AfterID workspaceHistory.ProvisionerState = arg.ProvisionerState q.workspaceHistory[index] = workspaceHistory diff --git a/database/dump.sql b/database/dump.sql index 0cea42a2355aa..bbbc34658445a 100644 --- a/database/dump.sql +++ b/database/dump.sql @@ -243,7 +243,6 @@ CREATE TABLE workspace_history ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, - completed_at timestamp with time zone, workspace_id uuid NOT NULL, project_history_id uuid NOT NULL, name character varying(64) NOT NULL, diff --git a/database/migrations/000003_workspaces.up.sql b/database/migrations/000003_workspaces.up.sql index 60fc1c0d9d8ab..fcc0b8fc3f77b 100644 --- a/database/migrations/000003_workspaces.up.sql +++ b/database/migrations/000003_workspaces.up.sql @@ -20,7 +20,6 @@ CREATE TABLE workspace_history ( id uuid NOT NULL UNIQUE, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, - completed_at timestamptz, workspace_id uuid NOT NULL REFERENCES workspace (id) ON DELETE CASCADE, project_history_id uuid NOT NULL REFERENCES project_history (id) ON DELETE CASCADE, name varchar(64) NOT NULL, diff --git a/database/models.go b/database/models.go index 6fd1dad97d4fd..440e11dd71a33 100644 --- a/database/models.go +++ b/database/models.go @@ -422,7 +422,6 @@ type WorkspaceHistory struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` ProjectHistoryID uuid.UUID `db:"project_history_id" json:"project_history_id"` Name string `db:"name" json:"name"` diff --git a/database/query.sql b/database/query.sql index 42f654a4bce9e..bcb8e297c38e7 100644 --- a/database/query.sql +++ b/database/query.sql @@ -619,8 +619,7 @@ UPDATE workspace_history SET updated_at = $2, - completed_at = $3, - after_id = $4, - provisioner_state = $5 + after_id = $3, + provisioner_state = $4 WHERE id = $1; diff --git a/database/query.sql.go b/database/query.sql.go index 0451cc54580c3..6293890f5e730 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -866,7 +866,7 @@ func (q *sqlQuerier) GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWor const getWorkspaceHistoryByID = `-- name: GetWorkspaceHistoryByID :one SELECT - id, created_at, updated_at, completed_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id + id, created_at, updated_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id FROM workspace_history WHERE @@ -882,7 +882,6 @@ func (q *sqlQuerier) GetWorkspaceHistoryByID(ctx context.Context, id uuid.UUID) &i.ID, &i.CreatedAt, &i.UpdatedAt, - &i.CompletedAt, &i.WorkspaceID, &i.ProjectHistoryID, &i.Name, @@ -898,7 +897,7 @@ func (q *sqlQuerier) GetWorkspaceHistoryByID(ctx context.Context, id uuid.UUID) const getWorkspaceHistoryByWorkspaceID = `-- name: GetWorkspaceHistoryByWorkspaceID :many SELECT - id, created_at, updated_at, completed_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id + id, created_at, updated_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id FROM workspace_history WHERE @@ -918,7 +917,6 @@ func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceID(ctx context.Context, works &i.ID, &i.CreatedAt, &i.UpdatedAt, - &i.CompletedAt, &i.WorkspaceID, &i.ProjectHistoryID, &i.Name, @@ -944,7 +942,7 @@ func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceID(ctx context.Context, works const getWorkspaceHistoryByWorkspaceIDAndName = `-- name: GetWorkspaceHistoryByWorkspaceIDAndName :one SELECT - id, created_at, updated_at, completed_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id + id, created_at, updated_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id FROM workspace_history WHERE @@ -964,7 +962,6 @@ func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceIDAndName(ctx context.Context &i.ID, &i.CreatedAt, &i.UpdatedAt, - &i.CompletedAt, &i.WorkspaceID, &i.ProjectHistoryID, &i.Name, @@ -980,7 +977,7 @@ func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceIDAndName(ctx context.Context const getWorkspaceHistoryByWorkspaceIDWithoutAfter = `-- name: GetWorkspaceHistoryByWorkspaceIDWithoutAfter :one SELECT - id, created_at, updated_at, completed_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id + id, created_at, updated_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id FROM workspace_history WHERE @@ -997,7 +994,6 @@ func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(ctx context.Co &i.ID, &i.CreatedAt, &i.UpdatedAt, - &i.CompletedAt, &i.WorkspaceID, &i.ProjectHistoryID, &i.Name, @@ -1939,7 +1935,7 @@ INSERT INTO provisioner_state ) VALUES - ($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 + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, workspace_id, project_history_id, name, before_id, after_id, transition, initiator, provisioner_state, provision_job_id ` type InsertWorkspaceHistoryParams struct { @@ -1975,7 +1971,6 @@ func (q *sqlQuerier) InsertWorkspaceHistory(ctx context.Context, arg InsertWorks &i.ID, &i.CreatedAt, &i.UpdatedAt, - &i.CompletedAt, &i.WorkspaceID, &i.ProjectHistoryID, &i.Name, @@ -2183,9 +2178,8 @@ UPDATE workspace_history SET updated_at = $2, - completed_at = $3, - after_id = $4, - provisioner_state = $5 + after_id = $3, + provisioner_state = $4 WHERE id = $1 ` @@ -2193,7 +2187,6 @@ WHERE type UpdateWorkspaceHistoryByIDParams struct { ID uuid.UUID `db:"id" json:"id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` AfterID uuid.NullUUID `db:"after_id" json:"after_id"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` } @@ -2202,7 +2195,6 @@ func (q *sqlQuerier) UpdateWorkspaceHistoryByID(ctx context.Context, arg UpdateW _, err := q.db.ExecContext(ctx, updateWorkspaceHistoryByID, arg.ID, arg.UpdatedAt, - arg.CompletedAt, arg.AfterID, arg.ProvisionerState, ) diff --git a/go.mod b/go.mod index 31b5af9012165..0143840304b6f 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,6 @@ 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 @@ -117,5 +116,4 @@ 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 ) From 0a8347755ac5166fe9ebd620ca70ca2fc5c65759 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 2 Feb 2022 00:56:08 +0000 Subject: [PATCH 07/11] Add endpoints for extracting singular history --- coderd/coderd.go | 9 ++- coderd/projecthistory.go | 86 ++++++++++++++++++++++------ coderd/projecthistory_test.go | 9 ++- coderd/provisionerdaemons.go | 7 +++ coderd/provisioners.go | 5 ++ coderd/workspacehistory.go | 62 ++++++++++---------- coderd/workspacehistory_test.go | 32 +++++++---- codersdk/projects.go | 22 +++++-- codersdk/projects_test.go | 17 +++--- codersdk/workspaces.go | 14 +++-- codersdk/workspaces_test.go | 6 +- httpmw/workspacehistoryparam.go | 28 ++++++--- httpmw/workspacehistoryparam_test.go | 31 ++++++++++ 13 files changed, 240 insertions(+), 88 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 8f8bd69b13fd9..b59dbb47b669f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) + }) }) }) }) @@ -84,7 +88,10 @@ 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) + }) }) }) }) diff --git a/coderd/projecthistory.go b/coderd/projecthistory.go index 7e8dbd74c3c5c..55c4cae55ec32 100644 --- a/coderd/projecthistory.go +++ b/coderd/projecthistory.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "database/sql" + "encoding/json" "errors" "fmt" "net/http" @@ -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" @@ -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. @@ -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. @@ -82,38 +106,68 @@ 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), } } diff --git a/coderd/projecthistory_test.go b/coderd/projecthistory_test.go index 4c9b727fbe358..f3a1922b0ea4c 100644 --- a/coderd/projecthistory_test.go +++ b/coderd/projecthistory_test.go @@ -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) }) @@ -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) { diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index b6d42c423240c..67cba4a68a6e0 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -241,6 +241,13 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty if err != nil { return nil, failJob(fmt.Sprintf("get project history: %s", err)) } + + protoJob.Type = &proto.AcquiredJob_ProjectImport_{ + ProjectImport: &proto.AcquiredJob_ProjectImport{ + ProjectHistoryId: projectHistory.ID.String(), + ProjectHistoryName: projectHistory.Name, + }, + } } switch projectHistory.StorageMethod { case database.ProjectStorageMethodInlineArchive: diff --git a/coderd/provisioners.go b/coderd/provisioners.go index f1eb3cf8f0fc2..711f963571346 100644 --- a/coderd/provisioners.go +++ b/coderd/provisioners.go @@ -10,6 +10,11 @@ import ( type ProvisionerJobStatus string +// Completed returns whether the job is still processing. +func (p ProvisionerJobStatus) Completed() bool { + return p == ProvisionerJobStatusSucceeded || p == ProvisionerJobStatusFailed +} + const ( ProvisionerJobStatusPending ProvisionerJobStatus = "pending" ProvisionerJobStatusRunning ProvisionerJobStatus = "running" diff --git a/coderd/workspacehistory.go b/coderd/workspacehistory.go index 624aa73756efb..77d068546a7e8 100644 --- a/coderd/workspacehistory.go +++ b/coderd/workspacehistory.go @@ -29,7 +29,7 @@ type WorkspaceHistory struct { AfterID uuid.UUID `json:"after_id"` Transition database.WorkspaceTransition `json:"transition"` Initiator string `json:"initiator"` - Job ProvisionerJob `json:"job"` + Provision ProvisionerJob `json:"provision"` } // CreateWorkspaceHistoryRequest provides options to update the latest workspace history. @@ -62,6 +62,27 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque }) return } + projectHistoryJob, 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 + } + projectHistoryJobStatus := convertProvisionerJob(projectHistoryJob).Status + switch projectHistoryJobStatus { + case ProvisionerJobStatusPending, ProvisionerJobStatusRunning: + httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ + Message: fmt.Sprintf("The provided project history is %s. Wait for it to complete importing!", projectHistoryJobStatus), + }) + return + case ProvisionerJobStatusFailed: + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("The provided project history %q has failed to import. You cannot create workspaces using it!", projectHistory.Name), + }) + return + } + project, err := api.Database.GetProjectByID(r.Context(), projectHistory.ProjectID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -75,15 +96,11 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque priorHistory, err := api.Database.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) if err == nil { priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.ProvisionJobID) - if err == nil { - convertedJob := convertProvisionerJob(priorJob) - if convertedJob.Status == ProvisionerJobStatusPending || - convertedJob.Status == ProvisionerJobStatusRunning { - httpapi.Write(rw, http.StatusConflict, httpapi.Response{ - Message: "a workspace build is already active", - }) - return - } + if err == nil && convertProvisionerJob(priorJob).Status.Completed() { + httpapi.Write(rw, http.StatusConflict, httpapi.Response{ + Message: "a workspace build is already active", + }) + return } priorHistoryID = uuid.NullUUID{ @@ -200,24 +217,9 @@ func (api *api) workspaceHistoryByUser(rw http.ResponseWriter, r *http.Request) 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 - } - job, err := api.Database.GetProvisionerJobByID(r.Context(), history.ProvisionJobID) +func (api *api) workspaceHistoryByName(rw http.ResponseWriter, r *http.Request) { + workspaceHistory := httpmw.WorkspaceHistoryParam(r) + job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceHistory.ProvisionJobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("get provisioner job: %s", err), @@ -226,7 +228,7 @@ func (api *api) latestWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Req } render.Status(r, http.StatusOK) - render.JSON(rw, r, convertWorkspaceHistory(history, job)) + render.JSON(rw, r, convertWorkspaceHistory(workspaceHistory, job)) } // Converts the internal history representation to a public external-facing model. @@ -242,7 +244,7 @@ func convertWorkspaceHistory(workspaceHistory database.WorkspaceHistory, provisi AfterID: workspaceHistory.AfterID.UUID, Transition: workspaceHistory.Transition, Initiator: workspaceHistory.Initiator, - Job: convertProvisionerJob(provisionerJob), + Provision: convertProvisionerJob(provisionerJob), }) } diff --git a/coderd/workspacehistory_test.go b/coderd/workspacehistory_test.go index 773de1a5b5a95..32e863ade7172 100644 --- a/coderd/workspacehistory_test.go +++ b/coderd/workspacehistory_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -32,7 +33,7 @@ func TestWorkspaceHistory(t *testing.T) { return project, workspace } - setupProjectHistory := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project) coderd.ProjectHistory { + setupProjectHistory := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project, files map[string]string) coderd.ProjectHistory { var buffer bytes.Buffer writer := tar.NewWriter(&buffer) err := writer.WriteHeader(&tar.Header{ @@ -47,24 +48,30 @@ func TestWorkspaceHistory(t *testing.T) { StorageSource: buffer.Bytes(), }) require.NoError(t, err) + require.Eventually(t, func() bool { + hist, err := client.ProjectHistory(context.Background(), user.Organization, project.Name, projectHistory.Name) + require.NoError(t, err) + return hist.Import.Status.Completed() + }, time.Second, 10*time.Millisecond) return projectHistory } t.Run("AllHistory", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) + _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) project, workspace := setupProjectAndWorkspace(t, server.Client, user) - history, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + history, err := server.Client.ListWorkspaceHistory(context.Background(), "", workspace.Name) require.NoError(t, err) require.Len(t, history, 0) - projectVersion := setupProjectHistory(t, server.Client, user, project) + projectVersion := setupProjectHistory(t, server.Client, user, project, map[string]string{}) _, 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) + history, err = server.Client.ListWorkspaceHistory(context.Background(), "", workspace.Name) require.NoError(t, err) require.Len(t, history, 1) }) @@ -72,27 +79,28 @@ func TestWorkspaceHistory(t *testing.T) { t.Run("LatestHistory", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) + _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) project, workspace := setupProjectAndWorkspace(t, server.Client, user) - _, err := server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + _, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name, "") require.Error(t, err) - projectVersion := setupProjectHistory(t, server.Client, user, project) + projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{}) _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ - ProjectHistoryID: projectVersion.ID, + ProjectHistoryID: projectHistory.ID, Transition: database.WorkspaceTransitionCreate, }) require.NoError(t, err) - _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + _, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name, "") require.NoError(t, err) }) t.Run("CreateHistory", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) + _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectHistory(t, server.Client, user, project) - + projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{}) _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ ProjectHistoryID: projectHistory.ID, Transition: database.WorkspaceTransitionCreate, @@ -103,9 +111,10 @@ func TestWorkspaceHistory(t *testing.T) { t.Run("CreateHistoryAlreadyInProgress", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) + _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectHistory(t, server.Client, user, project) + projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{}) _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ ProjectHistoryID: projectHistory.ID, @@ -123,6 +132,7 @@ func TestWorkspaceHistory(t *testing.T) { t.Run("CreateHistoryInvalidProjectVersion", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) + _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) _, workspace := setupProjectAndWorkspace(t, server.Client, user) diff --git a/codersdk/projects.go b/codersdk/projects.go index 4b3a4e90e15d6..a4281849c22e9 100644 --- a/codersdk/projects.go +++ b/codersdk/projects.go @@ -57,8 +57,8 @@ func (c *Client) CreateProject(ctx context.Context, organization string, request return project, json.NewDecoder(res.Body).Decode(&project) } -// ProjectHistory lists history for a project. -func (c *Client) ProjectHistory(ctx context.Context, organization, project string) ([]coderd.ProjectHistory, error) { +// ListProjectHistory lists history for a project. +func (c *Client) ListProjectHistory(ctx context.Context, organization, project string) ([]coderd.ProjectHistory, error) { res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/%s/history", organization, project), nil) if err != nil { return nil, err @@ -67,8 +67,22 @@ func (c *Client) ProjectHistory(ctx context.Context, organization, project strin if res.StatusCode != http.StatusOK { return nil, readBodyAsError(res) } - var projectVersions []coderd.ProjectHistory - return projectVersions, json.NewDecoder(res.Body).Decode(&projectVersions) + var projectHistory []coderd.ProjectHistory + return projectHistory, json.NewDecoder(res.Body).Decode(&projectHistory) +} + +// ProjectHistory returns project history by name. +func (c *Client) ProjectHistory(ctx context.Context, organization, project, history string) (coderd.ProjectHistory, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/projects/%s/%s/history/%s", organization, project, history), nil) + if err != nil { + return coderd.ProjectHistory{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return coderd.ProjectHistory{}, readBodyAsError(res) + } + var projectHistory coderd.ProjectHistory + return projectHistory, json.NewDecoder(res.Body).Decode(&projectHistory) } // CreateProjectHistory inserts a new version for the project. diff --git a/codersdk/projects_test.go b/codersdk/projects_test.go index ad61d79110288..ac6acef1784b6 100644 --- a/codersdk/projects_test.go +++ b/codersdk/projects_test.go @@ -71,14 +71,14 @@ func TestProjects(t *testing.T) { require.NoError(t, err) }) - t.Run("UnauthenticatedVersions", func(t *testing.T) { + t.Run("UnauthenticatedHistorys", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) - _, err := server.Client.ProjectHistory(context.Background(), "org", "project") + _, err := server.Client.ListProjectHistory(context.Background(), "org", "project") require.Error(t, err) }) - t.Run("Versions", func(t *testing.T) { + t.Run("History", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) user := server.RandomInitialUser(t) @@ -87,11 +87,11 @@ func TestProjects(t *testing.T) { Provisioner: database.ProvisionerTypeTerraform, }) require.NoError(t, err) - _, err = server.Client.ProjectHistory(context.Background(), user.Organization, project.Name) + _, err = server.Client.ListProjectHistory(context.Background(), user.Organization, project.Name) require.NoError(t, err) }) - t.Run("CreateVersionUnauthenticated", func(t *testing.T) { + t.Run("CreateHistoryUnauthenticated", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) _, err := server.Client.CreateProjectHistory(context.Background(), "org", "project", coderd.CreateProjectHistoryRequest{ @@ -101,7 +101,7 @@ func TestProjects(t *testing.T) { require.Error(t, err) }) - t.Run("CreateVersion", func(t *testing.T) { + t.Run("CreateHistory", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) user := server.RandomInitialUser(t) @@ -119,10 +119,13 @@ 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.CreateProjectHistoryRequest{ + history, err := server.Client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: buffer.Bytes(), }) require.NoError(t, err) + + _, err = server.Client.ProjectHistory(context.Background(), user.Organization, project.Name, history.Name) + require.NoError(t, err) }) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 937f58e861b11..122f66cdea906 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -60,8 +60,8 @@ func (c *Client) Workspace(ctx context.Context, owner, name string) (coderd.Work return workspace, json.NewDecoder(res.Body).Decode(&workspace) } -// WorkspaceHistory returns historical data for workspace builds. -func (c *Client) WorkspaceHistory(ctx context.Context, owner, workspace string) ([]coderd.WorkspaceHistory, error) { +// ListWorkspaceHistory returns historical data for workspace builds. +func (c *Client) ListWorkspaceHistory(ctx context.Context, owner, workspace string) ([]coderd.WorkspaceHistory, error) { if owner == "" { owner = "me" } @@ -77,12 +77,16 @@ func (c *Client) WorkspaceHistory(ctx context.Context, owner, workspace string) return workspaceHistory, json.NewDecoder(res.Body).Decode(&workspaceHistory) } -// LatestWorkspaceHistory returns the newest build for a workspace. -func (c *Client) LatestWorkspaceHistory(ctx context.Context, owner, workspace string) (coderd.WorkspaceHistory, error) { +// WorkspaceHistory returns a single workspace history for a workspace. +// If history is "", the latest version is returned. +func (c *Client) WorkspaceHistory(ctx context.Context, owner, workspace, history string) (coderd.WorkspaceHistory, error) { if owner == "" { owner = "me" } - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/%s/history/latest", owner, workspace), nil) + if history == "" { + history = "latest" + } + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/%s/history/%s", owner, workspace, history), nil) if err != nil { return coderd.WorkspaceHistory{}, err } diff --git a/codersdk/workspaces_test.go b/codersdk/workspaces_test.go index b99f3798e93ee..4b5e64d346d25 100644 --- a/codersdk/workspaces_test.go +++ b/codersdk/workspaces_test.go @@ -117,14 +117,14 @@ func TestWorkspaces(t *testing.T) { ProjectID: project.ID, }) require.NoError(t, err) - _, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name) + _, err = server.Client.ListWorkspaceHistory(context.Background(), "", workspace.Name) require.NoError(t, err) }) t.Run("HistoryError", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) - _, err := server.Client.WorkspaceHistory(context.Background(), "", "blob") + _, err := server.Client.ListWorkspaceHistory(context.Background(), "", "blob") require.Error(t, err) }) @@ -142,7 +142,7 @@ func TestWorkspaces(t *testing.T) { ProjectID: project.ID, }) require.NoError(t, err) - _, err = server.Client.LatestWorkspaceHistory(context.Background(), "", workspace.Name) + _, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name, "") require.Error(t, err) }) diff --git a/httpmw/workspacehistoryparam.go b/httpmw/workspacehistoryparam.go index cd43823b22d9c..ff426faf23c83 100644 --- a/httpmw/workspacehistoryparam.go +++ b/httpmw/workspacehistoryparam.go @@ -36,15 +36,27 @@ func ExtractWorkspaceHistoryParam(db database.Store) func(http.Handler) http.Han }) 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), + var workspaceHistory database.WorkspaceHistory + var err error + if workspaceHistoryName == "latest" { + workspaceHistory, err = db.GetWorkspaceHistoryByWorkspaceIDWithoutAfter(r.Context(), workspace.ID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: "there is no workspace history", + }) + return + } + } else { + workspaceHistory, err = db.GetWorkspaceHistoryByWorkspaceIDAndName(r.Context(), database.GetWorkspaceHistoryByWorkspaceIDAndNameParams{ + WorkspaceID: workspace.ID, + Name: workspaceHistoryName, }) - return + 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{ diff --git a/httpmw/workspacehistoryparam_test.go b/httpmw/workspacehistoryparam_test.go index 6fef05ed91c13..063f2fd7be3ca 100644 --- a/httpmw/workspacehistoryparam_test.go +++ b/httpmw/workspacehistoryparam_test.go @@ -142,4 +142,35 @@ func TestWorkspaceHistoryParam(t *testing.T) { defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) }) + + t.Run("WorkspaceHistoryLatest", 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) + _, 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", "latest") + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) } From e87f31db81b1f72d1550d561b68b1d1d5883be9b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 2 Feb 2022 01:20:39 +0000 Subject: [PATCH 08/11] The full end-to-end operation works --- .github/workflows/coder.yaml | 1 - .vscode/settings.json | 3 + coderd/cmd/root.go | 2 + coderd/coderdtest/coderdtest.go | 11 +- coderd/provisionerdaemons.go | 17 +-- coderd/provisioners.go | 8 +- coderd/workspacehistory.go | 4 +- coderd/workspacehistory_test.go | 42 +++++-- codersdk/projects_test.go | 2 +- database/databasefake/databasefake.go | 159 ++++++++++++++++++++++++++ provisioner/terraform/provision.go | 11 +- provisioner/terraform/serve.go | 5 + provisionerd/provisionerd.go | 4 +- provisionersdk/transport.go | 18 ++- 14 files changed, 258 insertions(+), 29 deletions(-) diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index cfc7cab5d92fb..c8e7d5356635c 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index f23e833a2c98d..3886c15fbfa72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ "coderdtest", "codersdk", "drpc", + "drpcconn", "drpcmux", "drpcserver", "goleak", @@ -44,6 +45,8 @@ "retrier", "sdkproto", "stretchr", + "tfexec", + "tfstate", "unconvert", "xerrors", "yamux" diff --git a/coderd/cmd/root.go b/coderd/cmd/root.go index 705cb60e511da..e63f4a50a901c 100644 --- a/coderd/cmd/root.go +++ b/coderd/cmd/root.go @@ -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" ) @@ -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) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 51cc23c5f0652..822cc8aa82669 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -78,13 +78,15 @@ func (s *Server) AddProvisionerd(t *testing.T) io.Closer { 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.LevelInfo), - PollInterval: 50 * time.Millisecond, + Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelInfo), + PollInterval: 50 * time.Millisecond, + UpdateInterval: 50 * time.Millisecond, Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(tfClient)), }, @@ -101,6 +103,7 @@ func (s *Server) AddProvisionerd(t *testing.T) io.Closer { 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) @@ -113,11 +116,15 @@ 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) } handler := coderd.New(&coderd.Options{ Logger: slogtest.Make(t, nil), Database: db, + Pubsub: pubsub, }) srv := httptest.NewServer(handler) serverURL, err := url.Parse(srv.URL) diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 67cba4a68a6e0..c98c3261a9825 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -33,6 +33,7 @@ func (api *api) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { daemons, err := api.Database.GetProvisionerDaemons(r.Context()) if errors.Is(err, sql.ErrNoRows) { err = nil + daemons = []database.ProvisionerDaemon{} } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ @@ -76,9 +77,10 @@ func (api *api) provisionerDaemonsServe(rw http.ResponseWriter, r *http.Request) } mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdServer{ - ID: daemon.ID, - Database: api.Database, - Pubsub: api.Pubsub, + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, }) if err != nil { _ = conn.Close(websocket.StatusInternalError, fmt.Sprintf("drpc register provisioner daemon: %s", err)) @@ -103,9 +105,10 @@ type projectImportJob struct { // Implementation of the provisioner daemon protobuf server. type provisionerdServer struct { - ID uuid.UUID - Database database.Store - Pubsub database.Pubsub + ID uuid.UUID + Provisioners []database.ProvisionerType + Database database.Store + Pubsub database.Pubsub } // AcquireJob queries the database to lock a job. @@ -120,7 +123,7 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty UUID: server.ID, Valid: true, }, - Types: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + Types: server.Provisioners, }) if errors.Is(err, sql.ErrNoRows) { // The provisioner daemon assumes no jobs are available if diff --git a/coderd/provisioners.go b/coderd/provisioners.go index 711f963571346..f2afefa00cbef 100644 --- a/coderd/provisioners.go +++ b/coderd/provisioners.go @@ -19,6 +19,7 @@ const ( ProvisionerJobStatusPending ProvisionerJobStatus = "pending" ProvisionerJobStatusRunning ProvisionerJobStatus = "running" ProvisionerJobStatusSucceeded ProvisionerJobStatus = "succeeded" + ProvisionerJobStatusCancelled ProvisionerJobStatus = "canceled" ProvisionerJobStatusFailed ProvisionerJobStatus = "failed" ) @@ -41,6 +42,7 @@ func convertProvisionerJob(provisionerJob database.ProvisionerJob) ProvisionerJo Error: provisionerJob.Error.String, Provisioner: provisionerJob.Provisioner, } + // Applying values optional to the struct. if provisionerJob.StartedAt.Valid { job.StartedAt = &provisionerJob.StartedAt.Time } @@ -56,7 +58,7 @@ func convertProvisionerJob(provisionerJob database.ProvisionerJob) ProvisionerJo switch { case provisionerJob.CancelledAt.Valid: - job.Status = ProvisionerJobStatusFailed + job.Status = ProvisionerJobStatusCancelled case !provisionerJob.StartedAt.Valid: job.Status = ProvisionerJobStatusPending case provisionerJob.CompletedAt.Valid: @@ -68,5 +70,9 @@ func convertProvisionerJob(provisionerJob database.ProvisionerJob) ProvisionerJo job.Status = ProvisionerJobStatusRunning } + if job.Error != "" { + job.Status = ProvisionerJobStatusFailed + } + return job } diff --git a/coderd/workspacehistory.go b/coderd/workspacehistory.go index 77d068546a7e8..f9e4c7690b4d3 100644 --- a/coderd/workspacehistory.go +++ b/coderd/workspacehistory.go @@ -27,6 +27,7 @@ type WorkspaceHistory struct { ProjectHistoryID uuid.UUID `json:"project_history_id"` BeforeID uuid.UUID `json:"before_id"` AfterID uuid.UUID `json:"after_id"` + Name string `json:"name"` Transition database.WorkspaceTransition `json:"transition"` Initiator string `json:"initiator"` Provision ProvisionerJob `json:"provision"` @@ -144,7 +145,7 @@ func (api *api) postWorkspaceHistoryByUser(rw http.ResponseWriter, r *http.Reque } workspaceHistory, err = db.InsertWorkspaceHistory(r.Context(), database.InsertWorkspaceHistoryParams{ - ID: uuid.New(), + ID: workspaceHistoryID, CreatedAt: database.Now(), UpdatedAt: database.Now(), WorkspaceID: workspace.ID, @@ -242,6 +243,7 @@ func convertWorkspaceHistory(workspaceHistory database.WorkspaceHistory, provisi ProjectHistoryID: workspaceHistory.ProjectHistoryID, BeforeID: workspaceHistory.BeforeID.UUID, AfterID: workspaceHistory.AfterID.UUID, + Name: workspaceHistory.Name, Transition: workspaceHistory.Transition, Initiator: workspaceHistory.Initiator, Provision: convertProvisionerJob(provisionerJob), diff --git a/coderd/workspacehistory_test.go b/coderd/workspacehistory_test.go index 32e863ade7172..82827053beb29 100644 --- a/coderd/workspacehistory_test.go +++ b/coderd/workspacehistory_test.go @@ -36,13 +36,18 @@ func TestWorkspaceHistory(t *testing.T) { setupProjectHistory := func(t *testing.T, client *codersdk.Client, user coderd.CreateInitialUserRequest, project coderd.Project, files map[string]string) 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)) + for path, content := range files { + err := writer.WriteHeader(&tar.Header{ + Name: path, + Size: int64(len(content)), + }) + require.NoError(t, err) + _, err = writer.Write([]byte(content)) + require.NoError(t, err) + } + err := writer.Flush() require.NoError(t, err) + projectHistory, err := client.CreateProjectHistory(context.Background(), user.Organization, project.Name, coderd.CreateProjectHistoryRequest{ StorageMethod: database.ProjectStorageMethodInlineArchive, StorageSource: buffer.Bytes(), @@ -65,7 +70,9 @@ func TestWorkspaceHistory(t *testing.T) { history, err := server.Client.ListWorkspaceHistory(context.Background(), "", workspace.Name) require.NoError(t, err) require.Len(t, history, 0) - projectVersion := setupProjectHistory(t, server.Client, user, project, map[string]string{}) + projectVersion := setupProjectHistory(t, server.Client, user, project, map[string]string{ + "example": "file", + }) _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ ProjectHistoryID: projectVersion.ID, Transition: database.WorkspaceTransitionCreate, @@ -84,7 +91,9 @@ func TestWorkspaceHistory(t *testing.T) { project, workspace := setupProjectAndWorkspace(t, server.Client, user) _, err := server.Client.WorkspaceHistory(context.Background(), "", workspace.Name, "") require.Error(t, err) - projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{}) + projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{ + "some": "file", + }) _, err = server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ ProjectHistoryID: projectHistory.ID, Transition: database.WorkspaceTransitionCreate, @@ -100,12 +109,23 @@ func TestWorkspaceHistory(t *testing.T) { _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{}) + projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{ + "main.tf": `resource "null_resource" "example" {}`, + }) _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ ProjectHistoryID: projectHistory.ID, Transition: database.WorkspaceTransitionCreate, }) require.NoError(t, err) + + var workspaceHistory coderd.WorkspaceHistory + require.Eventually(t, func() bool { + workspaceHistory, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name, "") + require.NoError(t, err) + return workspaceHistory.Provision.Status.Completed() + }, 5*time.Second, 50*time.Millisecond) + require.Equal(t, "", workspaceHistory.Provision.Error) + require.Equal(t, coderd.ProvisionerJobStatusSucceeded, workspaceHistory.Provision.Status) }) t.Run("CreateHistoryAlreadyInProgress", func(t *testing.T) { @@ -114,7 +134,9 @@ func TestWorkspaceHistory(t *testing.T) { _ = server.AddProvisionerd(t) user := server.RandomInitialUser(t) project, workspace := setupProjectAndWorkspace(t, server.Client, user) - projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{}) + projectHistory := setupProjectHistory(t, server.Client, user, project, map[string]string{ + "some": "content", + }) _, err := server.Client.CreateWorkspaceHistory(context.Background(), "", workspace.Name, coderd.CreateWorkspaceHistoryRequest{ ProjectHistoryID: projectHistory.ID, diff --git a/codersdk/projects_test.go b/codersdk/projects_test.go index ac6acef1784b6..a30146b7b97b7 100644 --- a/codersdk/projects_test.go +++ b/codersdk/projects_test.go @@ -71,7 +71,7 @@ func TestProjects(t *testing.T) { require.NoError(t, err) }) - t.Run("UnauthenticatedHistorys", func(t *testing.T) { + t.Run("UnauthenticatedHistory", func(t *testing.T) { t.Parallel() server := coderdtest.New(t) _, err := server.Client.ListProjectHistory(context.Background(), "org", "project") diff --git a/database/databasefake/databasefake.go b/database/databasefake/databasefake.go index 24c674ea371e2..7ddb71ba04751 100644 --- a/database/databasefake/databasefake.go +++ b/database/databasefake/databasefake.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "strings" + "sync" "github.com/google/uuid" @@ -35,6 +36,8 @@ func New() database.Store { // fakeQuerier replicates database functionality to enable quick testing. type fakeQuerier struct { + mutex sync.Mutex + // Legacy tables apiKeys []database.APIKey organizations []database.Organization @@ -62,6 +65,9 @@ func (q *fakeQuerier) InTx(fn func(database.Store) error) error { } func (q *fakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.AcquireProvisionerJobParams) (database.ProvisionerJob, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, provisionerJob := range q.provisionerJobs { if provisionerJob.StartedAt.Valid { continue @@ -87,6 +93,9 @@ func (q *fakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.Acqu } func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, apiKey := range q.apiKeys { if apiKey.ID == id { return apiKey, nil @@ -96,6 +105,9 @@ func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIK } func (q *fakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, user := range q.users { if user.Email == arg.Email || user.Username == arg.Username { return user, nil @@ -105,6 +117,9 @@ func (q *fakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.G } func (q *fakeQuerier) GetUserByID(_ context.Context, id string) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, user := range q.users { if user.ID == id { return user, nil @@ -114,10 +129,16 @@ func (q *fakeQuerier) GetUserByID(_ context.Context, id string) (database.User, } func (q *fakeQuerier) GetUserCount(_ context.Context) (int64, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + return int64(len(q.users)), nil } func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAgent, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + agents := make([]database.WorkspaceAgent, 0) for _, workspaceAgent := range q.workspaceAgent { for _, id := range ids { @@ -133,6 +154,9 @@ func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, ids []u } func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (database.Workspace, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, workspace := range q.workspace { if workspace.ID.String() == id.String() { return workspace, nil @@ -142,6 +166,9 @@ func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (databas } func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg database.GetWorkspaceByUserIDAndNameParams) (database.Workspace, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, workspace := range q.workspace { if workspace.OwnerID != arg.OwnerID { continue @@ -155,6 +182,9 @@ func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg databas } func (q *fakeQuerier) GetWorkspaceResourcesByHistoryID(_ context.Context, workspaceHistoryID uuid.UUID) ([]database.WorkspaceResource, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + resources := make([]database.WorkspaceResource, 0) for _, workspaceResource := range q.workspaceResource { if workspaceResource.WorkspaceHistoryID.String() == workspaceHistoryID.String() { @@ -168,6 +198,9 @@ func (q *fakeQuerier) GetWorkspaceResourcesByHistoryID(_ context.Context, worksp } func (q *fakeQuerier) GetWorkspaceHistoryByID(_ context.Context, id uuid.UUID) (database.WorkspaceHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, history := range q.workspaceHistory { if history.ID.String() == id.String() { return history, nil @@ -177,6 +210,9 @@ func (q *fakeQuerier) GetWorkspaceHistoryByID(_ context.Context, id uuid.UUID) ( } func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, workspaceHistory := range q.workspaceHistory { if workspaceHistory.WorkspaceID.String() != workspaceID.String() { continue @@ -189,6 +225,9 @@ func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(_ context.Con } func (q *fakeQuerier) GetWorkspaceHistoryLogsByIDBefore(_ context.Context, arg database.GetWorkspaceHistoryLogsByIDBeforeParams) ([]database.WorkspaceHistoryLog, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + logs := make([]database.WorkspaceHistoryLog, 0) for _, workspaceHistoryLog := range q.workspaceHistoryLog { if workspaceHistoryLog.WorkspaceHistoryID.String() != arg.WorkspaceHistoryID.String() { @@ -206,6 +245,9 @@ func (q *fakeQuerier) GetWorkspaceHistoryLogsByIDBefore(_ context.Context, arg d } func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceID(_ context.Context, workspaceID uuid.UUID) ([]database.WorkspaceHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + history := make([]database.WorkspaceHistory, 0) for _, workspaceHistory := range q.workspaceHistory { if workspaceHistory.WorkspaceID.String() == workspaceID.String() { @@ -219,6 +261,9 @@ func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceID(_ context.Context, worksp } func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceIDAndName(_ context.Context, arg database.GetWorkspaceHistoryByWorkspaceIDAndNameParams) (database.WorkspaceHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, workspaceHistory := range q.workspaceHistory { if workspaceHistory.WorkspaceID.String() != arg.WorkspaceID.String() { continue @@ -232,6 +277,9 @@ func (q *fakeQuerier) GetWorkspaceHistoryByWorkspaceIDAndName(_ context.Context, } func (q *fakeQuerier) GetWorkspacesByProjectAndUserID(_ context.Context, arg database.GetWorkspacesByProjectAndUserIDParams) ([]database.Workspace, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + workspaces := make([]database.Workspace, 0) for _, workspace := range q.workspace { if workspace.OwnerID != arg.OwnerID { @@ -249,6 +297,9 @@ func (q *fakeQuerier) GetWorkspacesByProjectAndUserID(_ context.Context, arg dat } func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, ownerID string) ([]database.Workspace, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + workspaces := make([]database.Workspace, 0) for _, workspace := range q.workspace { if workspace.OwnerID != ownerID { @@ -263,6 +314,9 @@ func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, ownerID string) ( } func (q *fakeQuerier) GetOrganizationByID(_ context.Context, id string) (database.Organization, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, organization := range q.organizations { if organization.ID == id { return organization, nil @@ -272,6 +326,9 @@ func (q *fakeQuerier) GetOrganizationByID(_ context.Context, id string) (databas } func (q *fakeQuerier) GetOrganizationByName(_ context.Context, name string) (database.Organization, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, organization := range q.organizations { if organization.Name == name { return organization, nil @@ -281,6 +338,9 @@ func (q *fakeQuerier) GetOrganizationByName(_ context.Context, name string) (dat } func (q *fakeQuerier) GetOrganizationsByUserID(_ context.Context, userID string) ([]database.Organization, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + organizations := make([]database.Organization, 0) for _, organizationMember := range q.organizationMembers { if organizationMember.UserID != userID { @@ -300,6 +360,9 @@ func (q *fakeQuerier) GetOrganizationsByUserID(_ context.Context, userID string) } func (q *fakeQuerier) GetParameterValuesByScope(_ context.Context, arg database.GetParameterValuesByScopeParams) ([]database.ParameterValue, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + parameterValues := make([]database.ParameterValue, 0) for _, parameterValue := range q.parameterValue { if parameterValue.Scope != arg.Scope { @@ -317,6 +380,9 @@ func (q *fakeQuerier) GetParameterValuesByScope(_ context.Context, arg database. } func (q *fakeQuerier) GetProjectByID(_ context.Context, id uuid.UUID) (database.Project, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, project := range q.project { if project.ID.String() == id.String() { return project, nil @@ -326,6 +392,9 @@ func (q *fakeQuerier) GetProjectByID(_ context.Context, id uuid.UUID) (database. } func (q *fakeQuerier) GetProjectByOrganizationAndName(_ context.Context, arg database.GetProjectByOrganizationAndNameParams) (database.Project, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, project := range q.project { if project.OrganizationID != arg.OrganizationID { continue @@ -339,6 +408,9 @@ func (q *fakeQuerier) GetProjectByOrganizationAndName(_ context.Context, arg dat } func (q *fakeQuerier) GetProjectHistoryByProjectID(_ context.Context, projectID uuid.UUID) ([]database.ProjectHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + history := make([]database.ProjectHistory, 0) for _, projectHistory := range q.projectHistory { if projectHistory.ProjectID.String() != projectID.String() { @@ -353,6 +425,9 @@ func (q *fakeQuerier) GetProjectHistoryByProjectID(_ context.Context, projectID } func (q *fakeQuerier) GetProjectHistoryByProjectIDAndName(_ context.Context, arg database.GetProjectHistoryByProjectIDAndNameParams) (database.ProjectHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, projectHistory := range q.projectHistory { if projectHistory.ProjectID.String() != arg.ProjectID.String() { continue @@ -366,6 +441,9 @@ func (q *fakeQuerier) GetProjectHistoryByProjectIDAndName(_ context.Context, arg } func (q *fakeQuerier) GetProjectHistoryLogsByIDBefore(_ context.Context, arg database.GetProjectHistoryLogsByIDBeforeParams) ([]database.ProjectHistoryLog, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + logs := make([]database.ProjectHistoryLog, 0) for _, projectHistoryLog := range q.projectHistoryLog { if projectHistoryLog.ProjectHistoryID.String() != arg.ProjectHistoryID.String() { @@ -383,6 +461,9 @@ func (q *fakeQuerier) GetProjectHistoryLogsByIDBefore(_ context.Context, arg dat } func (q *fakeQuerier) GetProjectHistoryByID(_ context.Context, projectHistoryID uuid.UUID) (database.ProjectHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, projectHistory := range q.projectHistory { if projectHistory.ID.String() != projectHistoryID.String() { continue @@ -393,6 +474,9 @@ func (q *fakeQuerier) GetProjectHistoryByID(_ context.Context, projectHistoryID } func (q *fakeQuerier) GetProjectParametersByHistoryID(_ context.Context, projectHistoryID uuid.UUID) ([]database.ProjectParameter, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + parameters := make([]database.ProjectParameter, 0) for _, projectParameter := range q.projectParameter { if projectParameter.ProjectHistoryID.String() != projectHistoryID.String() { @@ -407,6 +491,9 @@ func (q *fakeQuerier) GetProjectParametersByHistoryID(_ context.Context, project } func (q *fakeQuerier) GetProjectsByOrganizationIDs(_ context.Context, ids []string) ([]database.Project, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + projects := make([]database.Project, 0) for _, project := range q.project { for _, id := range ids { @@ -423,6 +510,9 @@ func (q *fakeQuerier) GetProjectsByOrganizationIDs(_ context.Context, ids []stri } func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, organizationMember := range q.organizationMembers { if organizationMember.OrganizationID != arg.OrganizationID { continue @@ -436,6 +526,9 @@ func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg datab } func (q *fakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.ProvisionerDaemon, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + if len(q.provisionerDaemons) == 0 { return nil, sql.ErrNoRows } @@ -443,6 +536,9 @@ func (q *fakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi } func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) (database.ProvisionerDaemon, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, provisionerDaemon := range q.provisionerDaemons { if provisionerDaemon.ID.String() != id.String() { continue @@ -453,6 +549,9 @@ func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) } func (q *fakeQuerier) GetProvisionerJobByID(_ context.Context, id uuid.UUID) (database.ProvisionerJob, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + for _, provisionerJob := range q.provisionerJobs { if provisionerJob.ID.String() != id.String() { continue @@ -463,6 +562,9 @@ func (q *fakeQuerier) GetProvisionerJobByID(_ context.Context, id uuid.UUID) (da } func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple key := database.APIKey{ ID: arg.ID, @@ -486,6 +588,9 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP } func (q *fakeQuerier) InsertOrganization(_ context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + organization := database.Organization{ ID: arg.ID, Name: arg.Name, @@ -497,6 +602,9 @@ func (q *fakeQuerier) InsertOrganization(_ context.Context, arg database.InsertO } func (q *fakeQuerier) InsertOrganizationMember(_ context.Context, arg database.InsertOrganizationMemberParams) (database.OrganizationMember, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple organizationMember := database.OrganizationMember{ OrganizationID: arg.OrganizationID, @@ -510,6 +618,9 @@ func (q *fakeQuerier) InsertOrganizationMember(_ context.Context, arg database.I } func (q *fakeQuerier) InsertParameterValue(_ context.Context, arg database.InsertParameterValueParams) (database.ParameterValue, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple parameterValue := database.ParameterValue{ ID: arg.ID, @@ -528,6 +639,9 @@ func (q *fakeQuerier) InsertParameterValue(_ context.Context, arg database.Inser } func (q *fakeQuerier) InsertProject(_ context.Context, arg database.InsertProjectParams) (database.Project, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + project := database.Project{ ID: arg.ID, CreatedAt: arg.CreatedAt, @@ -541,6 +655,9 @@ func (q *fakeQuerier) InsertProject(_ context.Context, arg database.InsertProjec } func (q *fakeQuerier) InsertProjectHistory(_ context.Context, arg database.InsertProjectHistoryParams) (database.ProjectHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple history := database.ProjectHistory{ ID: arg.ID, @@ -558,6 +675,9 @@ func (q *fakeQuerier) InsertProjectHistory(_ context.Context, arg database.Inser } func (q *fakeQuerier) InsertProjectHistoryLogs(_ context.Context, arg database.InsertProjectHistoryLogsParams) ([]database.ProjectHistoryLog, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + logs := make([]database.ProjectHistoryLog, 0) for index, output := range arg.Output { logs = append(logs, database.ProjectHistoryLog{ @@ -574,6 +694,9 @@ func (q *fakeQuerier) InsertProjectHistoryLogs(_ context.Context, arg database.I } func (q *fakeQuerier) InsertProjectParameter(_ context.Context, arg database.InsertProjectParameterParams) (database.ProjectParameter, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple param := database.ProjectParameter{ ID: arg.ID, @@ -599,6 +722,9 @@ func (q *fakeQuerier) InsertProjectParameter(_ context.Context, arg database.Ins } func (q *fakeQuerier) InsertProvisionerDaemon(_ context.Context, arg database.InsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + daemon := database.ProvisionerDaemon{ ID: arg.ID, CreatedAt: arg.CreatedAt, @@ -610,6 +736,9 @@ func (q *fakeQuerier) InsertProvisionerDaemon(_ context.Context, arg database.In } func (q *fakeQuerier) InsertProvisionerJob(_ context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + job := database.ProvisionerJob{ ID: arg.ID, CreatedAt: arg.CreatedAt, @@ -625,6 +754,9 @@ func (q *fakeQuerier) InsertProvisionerJob(_ context.Context, arg database.Inser } func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParams) (database.User, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + user := database.User{ ID: arg.ID, Email: arg.Email, @@ -640,6 +772,9 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam } func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple workspace := database.Workspace{ ID: arg.ID, @@ -654,6 +789,9 @@ func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork } func (q *fakeQuerier) InsertWorkspaceAgent(_ context.Context, arg database.InsertWorkspaceAgentParams) (database.WorkspaceAgent, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + //nolint:gosimple workspaceAgent := database.WorkspaceAgent{ ID: arg.ID, @@ -668,6 +806,9 @@ func (q *fakeQuerier) InsertWorkspaceAgent(_ context.Context, arg database.Inser } func (q *fakeQuerier) InsertWorkspaceHistory(_ context.Context, arg database.InsertWorkspaceHistoryParams) (database.WorkspaceHistory, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + workspaceHistory := database.WorkspaceHistory{ ID: arg.ID, CreatedAt: arg.CreatedAt, @@ -686,6 +827,9 @@ func (q *fakeQuerier) InsertWorkspaceHistory(_ context.Context, arg database.Ins } func (q *fakeQuerier) InsertWorkspaceHistoryLogs(_ context.Context, arg database.InsertWorkspaceHistoryLogsParams) ([]database.WorkspaceHistoryLog, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + logs := make([]database.WorkspaceHistoryLog, 0) for index, output := range arg.Output { logs = append(logs, database.WorkspaceHistoryLog{ @@ -702,6 +846,9 @@ func (q *fakeQuerier) InsertWorkspaceHistoryLogs(_ context.Context, arg database } func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.InsertWorkspaceResourceParams) (database.WorkspaceResource, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + workspaceResource := database.WorkspaceResource{ ID: arg.ID, CreatedAt: arg.CreatedAt, @@ -715,6 +862,9 @@ func (q *fakeQuerier) InsertWorkspaceResource(_ context.Context, arg database.In } func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, apiKey := range q.apiKeys { if apiKey.ID != arg.ID { continue @@ -731,6 +881,9 @@ func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI } func (q *fakeQuerier) UpdateProvisionerDaemonByID(_ context.Context, arg database.UpdateProvisionerDaemonByIDParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, daemon := range q.provisionerDaemons { if arg.ID.String() != daemon.ID.String() { continue @@ -744,6 +897,9 @@ func (q *fakeQuerier) UpdateProvisionerDaemonByID(_ context.Context, arg databas } func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, job := range q.provisionerJobs { if arg.ID.String() != job.ID.String() { continue @@ -759,6 +915,9 @@ func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.U } func (q *fakeQuerier) UpdateWorkspaceHistoryByID(_ context.Context, arg database.UpdateWorkspaceHistoryByIDParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + for index, workspaceHistory := range q.workspaceHistory { if workspaceHistory.ID.String() != arg.ID.String() { continue diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index fe0e9bec46425..e528abaaf44ea 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -38,6 +38,12 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP return xerrors.Errorf("terraform version %q is too old. required >= %q", version.String(), minimumTerraformVersion.String()) } + env := map[string]string{ + // Makes sequential runs significantly faster. + // https://github.com/hashicorp/terraform/blob/d35bc0531255b496beb5d932f185cbcdb2d61a99/internal/command/cliconfig/cliconfig.go#L24 + "TF_PLUGIN_CACHE_DIR": os.ExpandEnv("$HOME/.terraform.d/plugin-cache"), + } + reader, writer := io.Pipe() defer reader.Close() defer writer.Close() @@ -55,12 +61,13 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP } }() terraform.SetStdout(writer) + t.logger.Debug(ctx, "running initialization") err = terraform.Init(ctx) if err != nil { return xerrors.Errorf("initialize terraform: %w", err) } + t.logger.Debug(ctx, "ran initialization") - env := map[string]string{} options := []tfexec.ApplyOption{tfexec.JSON(true)} for _, param := range request.ParameterValues { switch param.DestinationScheme { @@ -124,10 +131,12 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP }() terraform.SetStdout(writer) + t.logger.Debug(ctx, "running apply") err = terraform.Apply(ctx, options...) if err != nil { return xerrors.Errorf("apply terraform: %w", err) } + t.logger.Debug(ctx, "ran apply") statefileContent, err := os.ReadFile(statefilePath) if err != nil { diff --git a/provisioner/terraform/serve.go b/provisioner/terraform/serve.go index 55323f393bf00..20b46bd3d625a 100644 --- a/provisioner/terraform/serve.go +++ b/provisioner/terraform/serve.go @@ -7,6 +7,8 @@ import ( "github.com/hashicorp/go-version" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/provisionersdk" ) @@ -29,6 +31,7 @@ type ServeOptions struct { // BinaryPath specifies the "terraform" binary to use. // If omitted, the $PATH will attempt to find it. BinaryPath string + Logger slog.Logger } // Serve starts a dRPC server on the provided transport speaking Terraform provisioner. @@ -43,9 +46,11 @@ func Serve(ctx context.Context, options *ServeOptions) error { return provisionersdk.Serve(ctx, &terraform{ binaryPath: options.BinaryPath, + logger: options.Logger, }, options.ServeOptions) } type terraform struct { binaryPath string + logger slog.Logger } diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 38cc8cfd34082..3feaca9c96727 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -239,7 +239,7 @@ func (p *provisionerDaemon) runJob(ctx context.Context) { return } - err := os.MkdirAll(p.opts.WorkDirectory, 0600) + err := os.MkdirAll(p.opts.WorkDirectory, 0700) if err != nil { p.cancelActiveJob(fmt.Sprintf("create work directory %q: %s", p.opts.WorkDirectory, err)) return @@ -277,7 +277,7 @@ func (p *provisionerDaemon) runJob(ctx context.Context) { case tar.TypeReg: file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, mode) if err != nil { - p.cancelActiveJob(fmt.Sprintf("create file %q: %s", path, err)) + p.cancelActiveJob(fmt.Sprintf("create file %q (mode %s): %s", path, mode, err)) return } // Max file size of 10MB. diff --git a/provisionersdk/transport.go b/provisionersdk/transport.go index 7fd87839d174b..3933aeb5efd7b 100644 --- a/provisionersdk/transport.go +++ b/provisionersdk/transport.go @@ -57,12 +57,16 @@ func (m *multiplexedDRPC) Closed() <-chan struct{} { return m.session.CloseChan() } -func (m *multiplexedDRPC) Invoke(ctx context.Context, rpc string, enc drpc.Encoding, in, out drpc.Message) error { +func (m *multiplexedDRPC) Invoke(ctx context.Context, rpc string, enc drpc.Encoding, inMessage, outMessage drpc.Message) error { conn, err := m.session.Open() if err != nil { return err } - return drpcconn.New(conn).Invoke(ctx, rpc, enc, in, out) + dConn := drpcconn.New(conn) + defer func() { + _ = dConn.Close() + }() + return dConn.Invoke(ctx, rpc, enc, inMessage, outMessage) } func (m *multiplexedDRPC) NewStream(ctx context.Context, rpc string, enc drpc.Encoding) (drpc.Stream, error) { @@ -70,5 +74,13 @@ func (m *multiplexedDRPC) NewStream(ctx context.Context, rpc string, enc drpc.En if err != nil { return nil, err } - return drpcconn.New(conn).NewStream(ctx, rpc, enc) + dConn := drpcconn.New(conn) + stream, err := dConn.NewStream(ctx, rpc, enc) + if err == nil { + go func() { + <-stream.Context().Done() + _ = dConn.Close() + }() + } + return stream, err } From 03ed951c8452336f007b3674d939225838fee463 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 2 Feb 2022 13:32:03 -0800 Subject: [PATCH 09/11] fix: Disable compression for websocket dRPC transport (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ✅ --- coderd/provisionerdaemons.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index c98c3261a9825..1a315402f08fc 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -48,7 +48,10 @@ func (api *api) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { // Serves the provisioner daemon protobuf API over a WebSocket. func (api *api) provisionerDaemonsServe(rw http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(rw, r, nil) + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + // Need to disable compression to avoid a data-race + CompressionMode: websocket.CompressionDisabled, + }) if err != nil { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("accept websocket: %s", err), From 9cc4756ef68b0cbf498fdf0e6ec4e117a871e041 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 2 Feb 2022 17:32:20 -0800 Subject: [PATCH 10/11] 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. --- provisionerd/provisionerd.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 051515304776f..94e7e3577800d 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -90,7 +90,7 @@ type provisionerDaemon struct { acquiredJobCancel context.CancelFunc acquiredJobCancelled atomic.Bool acquiredJobRunning atomic.Bool - acquiredJobDone chan struct{} + acquiredJobGroup sync.WaitGroup } // Connect establishes a connection to coderd. @@ -184,7 +184,7 @@ func (p *provisionerDaemon) acquireJob(ctx context.Context) { ctx, p.acquiredJobCancel = context.WithCancel(ctx) p.acquiredJobCancelled.Store(false) p.acquiredJobRunning.Store(true) - p.acquiredJobDone = make(chan struct{}) + p.acquiredJobGroup.Add(1) p.opts.Logger.Info(context.Background(), "acquired job", slog.F("organization_name", p.acquiredJob.OrganizationName), @@ -235,7 +235,7 @@ func (p *provisionerDaemon) runJob(ctx context.Context) { p.acquiredJobMutex.Lock() defer p.acquiredJobMutex.Unlock() p.acquiredJobRunning.Store(false) - close(p.acquiredJobDone) + p.acquiredJobGroup.Done() }() // It's safe to cast this ProvisionerType. This data is coming directly from coderd. provisioner, hasProvisioner := p.opts.Provisioners[p.acquiredJob.Provisioner] @@ -520,7 +520,7 @@ func (p *provisionerDaemon) closeWithError(err error) error { if !p.acquiredJobCancelled.Load() { p.cancelActiveJob(errMsg) } - <-p.acquiredJobDone + p.acquiredJobGroup.Wait() } p.opts.Logger.Debug(context.Background(), "closing server with error", slog.Error(err)) From 8282393bbdd2a227623469662ced7afabaa2f93b Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 2 Feb 2022 18:06:08 -0800 Subject: [PATCH 11/11] 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) ran apply t.go:56: 2022-02-02 21:33:21.991 [DEBUG] (provisionerd) skipping acquire; job is already running t.go:56: 2022-02-02 21:33:22.050 [DEBUG] (provisionerd) skipping acquire; job is already running t.go:56: 2022-02-02 21:33:22.090 [DEBUG] (provisionerd) skipping acquire; job is already running t.go:56: 2022-02-02 21:33:22.140 [DEBUG] (provisionerd) skipping acquire; job is already running t.go:56: 2022-02-02 21:33:22.195 [DEBUG] (provisionerd) skipping acquire; job is already running t.go:56: 2022-02-02 21:33:22.240 [DEBUG] (provisionerd) 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. --- coderd/workspacehistory_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspacehistory_test.go b/coderd/workspacehistory_test.go index 875ecc685bd0e..66dc5bd444621 100644 --- a/coderd/workspacehistory_test.go +++ b/coderd/workspacehistory_test.go @@ -123,7 +123,7 @@ func TestWorkspaceHistory(t *testing.T) { workspaceHistory, err = server.Client.WorkspaceHistory(context.Background(), "", workspace.Name, "") require.NoError(t, err) return workspaceHistory.Provision.Status.Completed() - }, 5*time.Second, 50*time.Millisecond) + }, 15*time.Second, 50*time.Millisecond) require.Equal(t, "", workspaceHistory.Provision.Error) require.Equal(t, coderd.ProvisionerJobStatusSucceeded, workspaceHistory.Provision.Status) })