From 98f00cdbf922644042c90e43728f52e122ba6d47 Mon Sep 17 00:00:00 2001 From: Louis Garman <75728+leg100@users.noreply.github.com> Date: Thu, 4 May 2023 20:05:40 +0100 Subject: [PATCH] Workspace Tags (#413) --- .gitignore | 2 - Makefile | 45 +- api/api.go | 4 +- api/marshaler.go | 4 +- api/tag.go | 171 ++++++ api/tag_marshaler.go | 22 + api/types/tag.go | 26 + api/types/workspace.go | 5 + api/workspace.go | 3 + api/workspace_marshaler.go | 1 + configversion/db.go | 2 +- daemon/daemon.go | 20 +- docker-compose.yml | 2 + hack/go-tfe-tests.bash | 3 +- http/html/paths/funcmap.go | 2 + http/html/paths/gen.go | 6 + http/html/paths/workspace_paths.go | 8 + http/html/static/css/forms.css | 29 ++ http/html/static/css/main.css | 75 ++- .../templates/content/workspace_edit.tmpl | 34 +- .../templates/content/workspace_get.tmpl | 58 ++- .../templates/content/workspace_list.tmpl | 12 + integration/tag_e2e_test.go | 109 ++++ integration/tag_service_test.go | 150 ++++++ integration/test_helpers.go | 6 +- integration/workspace_test.go | 56 +- organization/db.go | 2 +- otf.go | 15 + policy/policy.go | 3 - policy/service.go | 92 ---- rbac/action.go | 7 + rbac/action_string.go | 62 ++- rbac/role.go | 4 + repo/db.go | 5 +- repo/service.go | 2 +- run/db.go | 10 +- scheduler/scheduler.go | 2 +- ...0230427144312_add_table_workspace_tags.sql | 18 + sql/pggen/agent_token.sql.go | 178 ++++++- sql/pggen/configuration_version.sql.go | 12 +- sql/pggen/organization.sql.go | 14 +- sql/pggen/repo_connections.sql.go | 12 +- sql/pggen/run.sql.go | 12 +- sql/pggen/state_version.sql.go | 12 +- sql/pggen/tags.sql.go | 493 ++++++++++++++++++ sql/pggen/workspace.sql.go | 152 ++++-- sql/pggen/workspace_permission.sql.go | 97 ++-- sql/queries/organization.sql | 2 +- sql/queries/tags.sql | 117 +++++ sql/queries/workspace.sql | 77 ++- sql/queries/workspace_permission.sql | 23 +- state/db.go | 2 +- variable/service.go | 9 +- variable/web.go | 2 - workspace/authorizer.go | 32 ++ workspace/db.go | 27 +- workspace/lock_service.go | 4 +- policy/db.go => workspace/permissions_db.go | 41 +- workspace/permissions_service.go | 53 ++ workspace/service.go | 47 +- workspace/tag.go | 58 +++ workspace/tag_db.go | 171 ++++++ workspace/tag_service.go | 262 ++++++++++ workspace/tags_web.go | 57 ++ workspace/test_helpers.go | 7 +- workspace/web.go | 63 ++- workspace/web_test.go | 6 +- workspace/workspace.go | 9 +- 68 files changed, 2678 insertions(+), 450 deletions(-) create mode 100644 api/tag.go create mode 100644 api/tag_marshaler.go create mode 100644 api/types/tag.go create mode 100644 integration/tag_e2e_test.go create mode 100644 integration/tag_service_test.go delete mode 100644 policy/policy.go delete mode 100644 policy/service.go create mode 100644 sql/migrations/20230427144312_add_table_workspace_tags.sql create mode 100644 sql/pggen/tags.sql.go create mode 100644 sql/queries/tags.sql create mode 100644 workspace/authorizer.go rename policy/db.go => workspace/permissions_db.go (50%) create mode 100644 workspace/permissions_service.go create mode 100644 workspace/tag.go create mode 100644 workspace/tag_db.go create mode 100644 workspace/tag_service.go create mode 100644 workspace/tags_web.go diff --git a/.gitignore b/.gitignore index 1d233f7d2..abeee68f9 100644 --- a/.gitignore +++ b/.gitignore @@ -97,8 +97,6 @@ anaconda-mode/ Session.vim # temporary .netrwhist -# auto-generated tag files -tags ### VisualStudioCode ### .vscode/* .history diff --git a/Makefile b/Makefile index 8feee4bd8..123c0e87d 100644 --- a/Makefile +++ b/Makefile @@ -20,12 +20,22 @@ endif # go-tfe-tests runs API tests - before it does that, it builds the otfd docker # image and starts up otfd and postgres using docker compose, and then the -# tests are run against it +# tests are run against it. +# +# NOTE: two batches of tests are run: +# (1) using the forked repo +# (2) using the upstream repo, for tests against new features, like workspace tags .PHONY: go-tfe-tests -go-tfe-tests: image - docker compose up -d +go-tfe-tests: image compose-up go-tfe-tests-forked go-tfe-tests-upstream + +.PHONY: go-tfe-tests-forked +go-tfe-tests-forked: ./hack/go-tfe-tests.bash +.PHONY: go-tfe-tests-upstream +go-tfe-tests-upstream: + GO_TFE_REPO=github.com/hashicorp/go-tfe@latest ./hack/go-tfe-tests.bash 'Test(OrganizationTags|Workspaces_(Add|Remove)Tags)|TestWorkspacesList/when_searching_using_a_tag' + .PHONY: test test: go test ./... @@ -50,25 +60,25 @@ install-latest-release: unzip -o -d $(GOBIN) $$ZIP_FILE otfd ;\ } -# Run postgresql in a container +# Run docker compose stack +.PHONY: compose-up +compose-up: image + docker compose up -d + +# Remove docker compose stack +.PHONY: compose-rm +compose-rm: + docker compose rm -sf + +# Run postgresql via docker compose .PHONY: postgres postgres: - docker compose -f docker-compose.yml up -d postgres + docker compose up -d postgres -# Stop and remove postgres container -.PHONY: postgres-rm -postgres-rm: - docker compose -f docker-compose.yml rm -sf - -# Run squid caching proxy in a container +# Run squid via docker compose .PHONY: squid squid: - docker run --rm --name squid -t -d -p 3128:3128 -v $(PWD)/integration/fixtures:/etc/squid/certs leg100/squid:0.2 - -# Stop squid container -.PHONY: squid-stop -squid-stop: - docker stop --signal INT squid + docker compose up -d squid # Run staticcheck metalinter recursively against code .PHONY: lint @@ -115,6 +125,7 @@ sql: install-pggen --output-dir sql/pggen \ --go-type 'text=github.com/jackc/pgtype.Text' \ --go-type 'int4=int' \ + --go-type 'int8=int' \ --go-type 'bool=bool' \ --go-type 'bytea=[]byte' \ --acronym url \ diff --git a/api/api.go b/api/api.go index ffe393223..5e2043bca 100644 --- a/api/api.go +++ b/api/api.go @@ -9,7 +9,6 @@ import ( "github.com/leg100/otf/logr" "github.com/leg100/otf/organization" "github.com/leg100/otf/orgcreator" - "github.com/leg100/otf/policy" "github.com/leg100/otf/run" "github.com/leg100/otf/state" "github.com/leg100/otf/tokens" @@ -48,7 +47,6 @@ type ( auth.AuthService tokens.TokensService variable.VariableService - policy.PolicyService *surl.Signer @@ -73,7 +71,6 @@ func New(opts Options) *api { WorkspaceService: opts.WorkspaceService, RunService: opts.RunService, StateService: opts.StateService, - PolicyService: opts.PolicyService, runLogsURLGenerator: &runLogsURLGenerator{opts.Signer}, }, maxConfigSize: opts.MaxConfigSize, @@ -85,6 +82,7 @@ func (a *api) AddHandlers(r *mux.Router) { a.addRunHandlers(r) a.addWorkspaceHandlers(r) a.addStateHandlers(r) + a.addTagHandlers(r) a.addConfigHandlers(r) a.addUserHandlers(r) a.addTeamHandlers(r) diff --git a/api/marshaler.go b/api/marshaler.go index 1db31e1c7..dea39bd99 100644 --- a/api/marshaler.go +++ b/api/marshaler.go @@ -10,7 +10,6 @@ import ( "github.com/leg100/otf/auth" "github.com/leg100/otf/configversion" "github.com/leg100/otf/organization" - "github.com/leg100/otf/policy" "github.com/leg100/otf/run" "github.com/leg100/otf/state" "github.com/leg100/otf/variable" @@ -33,7 +32,6 @@ type ( organization.OrganizationService state.StateService workspace.WorkspaceService - policy.PolicyService *runLogsURLGenerator } @@ -84,6 +82,8 @@ func (m *jsonapiMarshaler) writeResponse(w http.ResponseWriter, r *http.Request, payload = m.toUser(v) case *auth.Team: payload = m.toTeam(v) + case *workspace.TagList: + payload, marshalOpts = m.toTags(v) default: Error(w, fmt.Errorf("cannot marshal unknown type: %T", v)) return diff --git a/api/tag.go b/api/tag.go new file mode 100644 index 000000000..cd1cb7a03 --- /dev/null +++ b/api/tag.go @@ -0,0 +1,171 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gorilla/mux" + "github.com/leg100/otf/api/types" + otfhttp "github.com/leg100/otf/http" + "github.com/leg100/otf/http/decode" + "github.com/leg100/otf/workspace" +) + +const ( + addTags tagOperation = iota + removeTags +) + +type tagOperation int + +func (a *api) addTagHandlers(r *mux.Router) { + r = otfhttp.APIRouter(r) + + r.HandleFunc("/workspaces/{workspace_id}/relationships/tags", a.addTags).Methods("POST") + r.HandleFunc("/workspaces/{workspace_id}/relationships/tags", a.removeTags).Methods("DELETE") + r.HandleFunc("/workspaces/{workspace_id}/relationships/tags", a.getTags).Methods("GET") + + r.HandleFunc("/organizations/{organization_name}/tags", a.listTags).Methods("GET") + r.HandleFunc("/organizations/{organization_name}/tags", a.deleteTags).Methods("DELETE") + r.HandleFunc("/tags/{tag_id}/relationships/workspaces", a.tagWorkspaces).Methods("POST") +} + +func (a *api) listTags(w http.ResponseWriter, r *http.Request) { + org, err := decode.Param("organization_name", r) + if err != nil { + Error(w, err) + return + } + var params workspace.ListTagsOptions + if err := decode.All(¶ms, r); err != nil { + Error(w, err) + return + } + + tags, err := a.ListTags(r.Context(), org, params) + if err != nil { + Error(w, err) + return + } + + a.writeResponse(w, r, tags) +} + +func (a *api) deleteTags(w http.ResponseWriter, r *http.Request) { + org, err := decode.Param("organization_name", r) + if err != nil { + Error(w, err) + return + } + var params []struct { + ID string `jsonapi:"primary,tags"` + } + if err := unmarshal(r.Body, ¶ms); err != nil { + Error(w, err) + return + } + var tagIDs []string + for _, p := range params { + tagIDs = append(tagIDs, p.ID) + } + + if err := a.DeleteTags(r.Context(), org, tagIDs); err != nil { + Error(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a *api) tagWorkspaces(w http.ResponseWriter, r *http.Request) { + tagID, err := decode.Param("tag_id", r) + if err != nil { + Error(w, err) + return + } + var params []*types.Workspace + if err := unmarshal(r.Body, ¶ms); err != nil { + Error(w, err) + return + } + var workspaceIDs []string + for _, p := range params { + workspaceIDs = append(workspaceIDs, p.ID) + } + + if err := a.TagWorkspaces(r.Context(), tagID, workspaceIDs); err != nil { + Error(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a *api) addTags(w http.ResponseWriter, r *http.Request) { + a.alterWorkspaceTags(w, r, addTags) +} + +func (a *api) removeTags(w http.ResponseWriter, r *http.Request) { + a.alterWorkspaceTags(w, r, removeTags) +} + +func (a *api) alterWorkspaceTags(w http.ResponseWriter, r *http.Request, op tagOperation) { + workspaceID, err := decode.Param("workspace_id", r) + if err != nil { + Error(w, err) + return + } + var params []*types.Tag + if err := unmarshal(r.Body, ¶ms); err != nil { + Error(w, err) + return + } + // convert from json:api structs to tag specs + specs := toTagSpecs(params) + + switch op { + case addTags: + err = a.AddTags(r.Context(), workspaceID, specs) + case removeTags: + err = a.RemoveTags(r.Context(), workspaceID, specs) + default: + err = errors.New("unknown tag operation") + } + if err != nil { + Error(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a *api) getTags(w http.ResponseWriter, r *http.Request) { + workspaceID, err := decode.Param("workspace_id", r) + if err != nil { + Error(w, err) + return + } + var params workspace.ListWorkspaceTagsOptions + if err := decode.All(¶ms, r); err != nil { + Error(w, err) + return + } + + tags, err := a.ListWorkspaceTags(r.Context(), workspaceID, params) + if err != nil { + Error(w, err) + return + } + + a.writeResponse(w, r, tags) +} + +func toTagSpecs(from []*types.Tag) (to []workspace.TagSpec) { + for _, tag := range from { + to = append(to, workspace.TagSpec{ + ID: tag.ID, + Name: tag.Name, + }) + } + return +} diff --git a/api/tag_marshaler.go b/api/tag_marshaler.go new file mode 100644 index 000000000..451e78d62 --- /dev/null +++ b/api/tag_marshaler.go @@ -0,0 +1,22 @@ +package api + +import ( + "github.com/DataDog/jsonapi" + "github.com/leg100/otf/api/types" + "github.com/leg100/otf/workspace" +) + +func (m *jsonapiMarshaler) toTags(from *workspace.TagList) (to []*types.OrganizationTag, opts []jsonapi.MarshalOption) { + for _, ft := range from.Items { + to = append(to, &types.OrganizationTag{ + ID: ft.ID, + Name: ft.Name, + InstanceCount: ft.InstanceCount, + Organization: &types.Organization{ + Name: ft.Organization, + }, + }) + } + opts = []jsonapi.MarshalOption{toMarshalOption(from.Pagination)} + return +} diff --git a/api/types/tag.go b/api/types/tag.go new file mode 100644 index 000000000..391245106 --- /dev/null +++ b/api/types/tag.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +type ( + // OrganizationTag represents a Terraform Enterprise Organization tag + OrganizationTag struct { + ID string `jsonapi:"primary,tags"` + + // Optional: + Name string `jsonapi:"attribute" json:"name,omitempty"` + + // Optional: Number of workspaces that have this tag + InstanceCount int `jsonapi:"attribute" json:"instance-count,omitempty"` + + // The org this tag belongs to + Organization *Organization `jsonapi:"relationship" json:"organization"` + } + + // Tag is owned by an organization and applied to workspaces. Used for grouping and search. + Tag struct { + ID string `jsonapi:"primary,tags"` + Name string `jsonapi:"attr,name,omitempty"` + } +) diff --git a/api/types/workspace.go b/api/types/workspace.go index 7ebcb2ed2..a7315c43f 100644 --- a/api/types/workspace.go +++ b/api/types/workspace.go @@ -45,6 +45,7 @@ type Workspace struct { PolicyCheckFailures int `jsonapi:"attribute" json:"policy-check-failures"` RunFailures int `jsonapi:"attribute" json:"run-failures"` RunsCount int `jsonapi:"attribute" json:"workspace-kpis-runs-count"` + TagNames []string `jsonapi:"attribute" json:"tag-names"` // Relations CurrentRun *Run `jsonapi:"relationship" json:"current-run"` @@ -184,6 +185,10 @@ type WorkspaceCreateOptions struct { // root of your repository and is typically set to a subdirectory matching the // environment when multiple environments exist within the same repository. WorkingDirectory *string `jsonapi:"attribute" json:"working-directory,omitempty"` + + // A list of tags to attach to the workspace. If the tag does not already + // exist, it is created and added to the workspace. + Tags []*Tag `jsonapi:"relationship" json:"tags,omitempty"` } // WorkspaceUpdateOptions represents the options for updating a workspace. diff --git a/api/workspace.go b/api/workspace.go index 214a71fc9..245d79466 100644 --- a/api/workspace.go +++ b/api/workspace.go @@ -47,6 +47,7 @@ func (a *api) createWorkspace(w http.ResponseWriter, r *http.Request) { Error(w, err) return } + opts := workspace.CreateOptions{ AllowDestroyPlan: params.AllowDestroyPlan, AutoApply: params.AutoApply, @@ -65,6 +66,8 @@ func (a *api) createWorkspace(w http.ResponseWriter, r *http.Request) { TerraformVersion: params.TerraformVersion, TriggerPrefixes: params.TriggerPrefixes, WorkingDirectory: params.WorkingDirectory, + // convert from json:api structs to tag specs + Tags: toTagSpecs(params.Tags), } if params.Operations != nil { if params.ExecutionMode != nil { diff --git a/api/workspace_marshaler.go b/api/workspace_marshaler.go index b3d0ff7c4..389c671ac 100644 --- a/api/workspace_marshaler.go +++ b/api/workspace_marshaler.go @@ -61,6 +61,7 @@ func (m *jsonapiMarshaler) toWorkspace(from *workspace.Workspace, r *http.Reques TerraformVersion: from.TerraformVersion, TriggerPrefixes: from.TriggerPrefixes, WorkingDirectory: from.WorkingDirectory, + TagNames: from.Tags, UpdatedAt: from.UpdatedAt, Organization: &types.Organization{Name: from.Organization}, Outputs: []*types.StateVersionOutput{}, diff --git a/configversion/db.go b/configversion/db.go index 207cd8d03..2f0bc9940 100644 --- a/configversion/db.go +++ b/configversion/db.go @@ -96,7 +96,7 @@ func (db *pgdb) ListConfigurationVersions(ctx context.Context, workspaceID strin return &ConfigurationVersionList{ Items: items, - Pagination: otf.NewPagination(opts.ListOptions, *count), + Pagination: otf.NewPagination(opts.ListOptions, count), }, nil } diff --git a/daemon/daemon.go b/daemon/daemon.go index d2753c797..c5e390efd 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -24,7 +24,6 @@ import ( "github.com/leg100/otf/module" "github.com/leg100/otf/organization" "github.com/leg100/otf/orgcreator" - "github.com/leg100/otf/policy" "github.com/leg100/otf/pubsub" "github.com/leg100/otf/repo" "github.com/leg100/otf/run" @@ -62,7 +61,6 @@ type ( run.RunService repo.RepoService logs.LogsService - policy.PolicyService Handlers []otf.Handlers } @@ -167,11 +165,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { VCSProviderService: vcsProviderService, }) - policyService := policy.NewService(policy.Options{ - Logger: logger, - DB: db, - }) - workspaceService := workspace.NewService(workspace.Options{ Logger: logger, DB: db, @@ -181,13 +174,11 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { TeamService: authService, OrganizationService: orgService, VCSProviderService: vcsProviderService, - WorkspaceAuthorizer: policyService, - PolicyService: policyService, }) configService := configversion.NewService(configversion.Options{ Logger: logger, DB: db, - WorkspaceAuthorizer: policyService, + WorkspaceAuthorizer: workspaceService, Cache: cache, Signer: signer, }) @@ -195,7 +186,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { Logger: logger, DB: db, Renderer: renderer, - WorkspaceAuthorizer: policyService, + WorkspaceAuthorizer: workspaceService, WorkspaceService: workspaceService, ConfigurationVersionService: configService, VCSProviderService: vcsProviderService, @@ -222,7 +213,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { stateService := state.NewService(state.Options{ Logger: logger, DB: db, - WorkspaceAuthorizer: policyService, + WorkspaceAuthorizer: workspaceService, WorkspaceService: workspaceService, Cache: cache, }) @@ -230,9 +221,8 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { Logger: logger, DB: db, Renderer: renderer, - WorkspaceAuthorizer: policyService, + WorkspaceAuthorizer: workspaceService, WorkspaceService: workspaceService, - PolicyService: policyService, }) agent, err := agent.NewAgent( @@ -279,7 +269,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { AuthService: authService, TokensService: tokensService, VariableService: variableService, - PolicyService: policyService, Signer: signer, MaxConfigSize: cfg.MaxConfigSize, }) @@ -321,7 +310,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { RunService: runService, LogsService: logsService, RepoService: repoService, - PolicyService: policyService, Broker: broker, DB: db, agent: agent, diff --git a/docker-compose.yml b/docker-compose.yml index eb04d04d9..e3f1b1037 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: condition: service_healthy squid: image: leg100/squid + ports: + - "3128:3128" healthcheck: test: ["CMD-SHELL", "nc -zw1 localhost 3128"] interval: 5s diff --git a/hack/go-tfe-tests.bash b/hack/go-tfe-tests.bash index e316e18a7..20a9ae5de 100755 --- a/hack/go-tfe-tests.bash +++ b/hack/go-tfe-tests.bash @@ -9,6 +9,7 @@ set -e +GO_TFE_REPO="${GO_TFE_REPO:-github.com/leg100/go-tfe@otf}" TESTS="${@:-Test(Variables|Workspaces(Create|List|Update|Delete|Lock|Unlock|ForceUnlock|Read\$|ReadByID)|Organizations(Create|List|Read|Update)|StateVersion|Runs|Plans|Applies(Read|Logs)|ConfigurationVersions)}" export TFE_ADDRESS="${TFE_ADDRESS:-https://localhost:8833}" @@ -20,5 +21,5 @@ export TFE_TOKEN=${TFE_TOKEN:-site-token} export SKIP_PAID=1 export SSL_CERT_FILE=$PWD/integration/fixtures/cert.pem -cd $(go mod download -json github.com/leg100/go-tfe@otf | jq -r '.Dir') +cd $(go mod download -json ${GO_TFE_REPO} | jq -r '.Dir') go test -v -run $TESTS -timeout 60s diff --git a/http/html/paths/funcmap.go b/http/html/paths/funcmap.go index d4a9b0043..15829a422 100644 --- a/http/html/paths/funcmap.go +++ b/http/html/paths/funcmap.go @@ -51,6 +51,8 @@ func init() { funcmap["startRunWorkspacePath"] = StartRunWorkspace funcmap["setupConnectionProviderWorkspacePath"] = SetupConnectionProviderWorkspace funcmap["setupConnectionRepoWorkspacePath"] = SetupConnectionRepoWorkspace + funcmap["createTagWorkspacePath"] = CreateTagWorkspace + funcmap["deleteTagWorkspacePath"] = DeleteTagWorkspace funcmap["runsPath"] = Runs funcmap["createRunPath"] = CreateRun diff --git a/http/html/paths/gen.go b/http/html/paths/gen.go index 9cf7b73bf..bb4bfe1bd 100644 --- a/http/html/paths/gen.go +++ b/http/html/paths/gen.go @@ -170,6 +170,12 @@ var specs = []controllerSpec{ { name: "setup-connection-repo", }, + { + name: "create-tag", + }, + { + name: "delete-tag", + }, }, nested: []controllerSpec{ { diff --git a/http/html/paths/workspace_paths.go b/http/html/paths/workspace_paths.go index 5de4ff3a0..f56b52344 100644 --- a/http/html/paths/workspace_paths.go +++ b/http/html/paths/workspace_paths.go @@ -75,3 +75,11 @@ func SetupConnectionProviderWorkspace(workspace string) string { func SetupConnectionRepoWorkspace(workspace string) string { return fmt.Sprintf("/app/workspaces/%s/setup-connection-repo", workspace) } + +func CreateTagWorkspace(workspace string) string { + return fmt.Sprintf("/app/workspaces/%s/create-tag", workspace) +} + +func DeleteTagWorkspace(workspace string) string { + return fmt.Sprintf("/app/workspaces/%s/delete-tag", workspace) +} diff --git a/http/html/static/css/forms.css b/http/html/static/css/forms.css index 8d39aba44..46c455a65 100644 --- a/http/html/static/css/forms.css +++ b/http/html/static/css/forms.css @@ -115,7 +115,36 @@ button:disabled, button:disabled:hover { title: "foo"; } + +button.cross { + padding: 0.2em 0.5em; + background: #ed6969; + color: white; + font-weight: 700; + line-height: 1rem; +} + input.error { border-color: #e72633; box-shadow: 0 0 0 .125em rgba(229, 43, 37, 0.5); } + +/* + * Workspace listing + */ + +.workspace-tag-filter-checkbox { + display: none; +} + +.workspace-tag-filter-label { + background: #c0c0c0; + color: white; + padding: 0.2em; + font-size: 0.9em; + font-weight: 700; +} + +.workspace-tag-filter-checkbox:checked ~ .workspace-tag-filter-label { + background: #2f35ab; +} diff --git a/http/html/static/css/main.css b/http/html/static/css/main.css index 1e43e26c5..c508c6815 100644 --- a/http/html/static/css/main.css +++ b/http/html/static/css/main.css @@ -162,10 +162,10 @@ header nav { } /* - * Workspace permissions + * Workspace settings */ -.permissions-container { +.settings-container { display: flex; flex-direction: column; gap: 0.8em; @@ -173,7 +173,7 @@ header nav { border-top: 1px solid var(--faint-grey); } -.permissions-container .container-header-title { +.settings-container .container-header-title { font-size: 1.2em; font-weight: bolder; } @@ -188,24 +188,24 @@ header nav { color: grey; } -.permissions-container table { +.settings-container table { text-align: left; font-size: 0.9em; border-collapse: collapse; letter-spacing: 1px; } -.permissions-container thead { +.settings-container thead { background-color: var(--faint-grey); border-top: 1px solid #bac1cc; border-bottom: 1px solid #bac1cc; } -.permissions-container td, th { +.settings-container td, th { padding: 10px; } -.permissions-container tbody tr { +.settings-container tbody tr { border-bottom: 1px solid #dce0e6; } @@ -215,6 +215,34 @@ header nav { gap: 1em; } +.workspace-tag { + background: #2f35ab; + color: white; + padding: 0.2em; + font-size: 0.9em; + font-weight: 700; +} + +/* list of tags on the main workspace page */ +.workspace-tags-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.3em; +} + +/* workspace-tag-settings-list is the list of tags on the + * workspace settings page */ +.workspace-tag-settings-list { + display: flex; + flex-direction: row; + gap: 1em; +} + +.workspace-tag-settings-list form { + line-height: 1em; +} + /* * Variables table */ @@ -423,6 +451,7 @@ a.show-underline { display: flex; align-items: center; gap: 0.5em; + padding: 0.5em; } .workspace-lock form { @@ -438,6 +467,10 @@ a.show-underline { background: #d4fdd4; } +.workspace-lock-info { + font-size: 0.9em; +} + /* color-coded run status field */ .status { font-size: 1.1rem; @@ -527,6 +560,34 @@ a.show-underline { font-size: 1.2em; } +/* two-column is a two column layout for the main content, using flex-box. */ +.two-column { + display: flex; + gap: 2em; + flex-direction: row; +} + +.two-column-main-column { + flex-grow: 1; +} + +.two-column-side-column { + flex-basis: 13em; + + display: flex; + gap: 0.7em; + flex-direction: column; +} + +.two-column-side-column > * { + padding: 0.5em; + background: #f6f6f6; +} + +.two-column-side-column h5 { + margin-block-start: 0em; +} + /* data is a nugget of factual information, e.g. terraform version */ .data { font-family: var(--alt-font); diff --git a/http/html/static/templates/content/workspace_edit.tmpl b/http/html/static/templates/content/workspace_edit.tmpl index 715f9c10f..1f3e07c02 100644 --- a/http/html/static/templates/content/workspace_edit.tmpl +++ b/http/html/static/templates/content/workspace_edit.tmpl @@ -18,6 +18,8 @@ {{ $canSetPermission := $.CurrentUser.CanAccessWorkspace .SetWorkspacePermissionAction .Policy }} {{ $canUnsetPermission := $.CurrentUser.CanAccessWorkspace .UnsetWorkspacePermissionAction .Policy }} {{ $canCreateRun := $.CurrentUser.CanAccessWorkspace .CreateRunAction .Policy }} + {{ $canAddTag := $.CurrentUser.CanAccessWorkspace .AddTagsAction .Policy }} + {{ $canRemoveTag := $.CurrentUser.CanAccessWorkspace .RemoveTagsAction .Policy }} <div> {{ with .Workspace.Connection }} <form action="{{ disconnectWorkspacePath $.Workspace.ID }}" method="POST"> @@ -70,7 +72,35 @@ <button {{ insufficient $canUpdate }}>Save changes</button> </div> </form> - <div class="permissions-container"> + <div class="settings-container"> + <h3>Tags</h3> + <div class="workspace-tag-settings-list"> + {{ range .Workspace.Tags }} + <form action="{{ deleteTagWorkspacePath $.Workspace.ID }}" method="POST"> + <span class="workspace-tag">{{ . }}</span> + <input type="hidden" name="tag_name" id="remove-tag-name" value="{{ . }}" required> + <button id="button-remove-tag-{{ . }}" class="delete cross" {{ insufficient $canAddTag }}>x</button> + </form> + {{ end }} + </div> + <form action="{{ createTagWorkspacePath .Workspace.ID }}" method="POST"> + <div class="field"> + <input type="text" name="tag_name" id="new-tag-name" value="" required> + <button {{ insufficient $canAddTag }}>Add new tag</button> + </div> + </form> + <form action="{{ createTagWorkspacePath .Workspace.ID }}" method="POST"> + <select name="tag_name" id="existing-tag-name"> + {{ range .UnassignedTags }} + <option value="{{ . }}">{{ . }}</option> + {{ else }} + <option value="" disabled>no tags found</option> + {{ end }} + </select> + <button {{ insufficient $canAddTag }}>Add existing tag</button> + </form> + </div> + <div class="settings-container" id="permissions-container"> <h3>Permissions</h3> <div> <table> @@ -138,7 +168,7 @@ </tbody> </table> </div> - <div class="permissions-container"> + <div class="settings-container"> <h3>Advanced</h3> <form action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST"> <button id="queue-destroy-plan-button" class="delete" {{ insufficient $canCreateRun }} onclick="return confirm('This will destroy all infrastructure in this workspace. Please confirm.')"> diff --git a/http/html/static/templates/content/workspace_get.tmpl b/http/html/static/templates/content/workspace_get.tmpl index e80a02b70..12334d021 100644 --- a/http/html/static/templates/content/workspace_get.tmpl +++ b/http/html/static/templates/content/workspace_get.tmpl @@ -14,28 +14,9 @@ watchWorkspaceUpdates({{ watchWorkspacePath .Workspace.ID }}); }); </script> - <div class="content-menu"> - <div class="workspace-info"> + <div class="two-column"> + <div class="two-column-main-column"> <div>{{ template "identifier" .Workspace }}</span></div> - <div>Terraform Version: <span class="data">v{{ .Workspace.TerraformVersion }}</span></div> - <div class="workspace-lock"> - {{ with .LockButton }} - <span class="workspace-lock-status workspace-lock-status-{{ .State }}" title="{{ .Tooltip }}">{{ title .State }}</span> - <form action="{{ .Action }}" method="POST"><button title="{{ .Tooltip }}" {{ disabled .Disabled }}>{{ .Text }}</button></form> - {{ end }} - </div> - <form id="workspace-start-run-form" action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST"> - <select name="strategy" id="start-run-strategy" onchange="this.form.submit()"> - <option value="" selected>--start run--</option> - <option value="plan-only">plan only</option> - <option value="plan-and-apply">plan and apply</option> - </select> - </form> - </div> - {{ with .Workspace.Connection }} - <div>Connected to <span class="data">{{ .Repo }} ({{ $.VCSProvider.CloudConfig }})</span></div> - {{ end }} - <div> <h3>Latest Run</h3> <div id="latest-run"> {{ if not .Workspace.LatestRun }} @@ -43,6 +24,41 @@ {{ end }} </div> </div> + <div class="two-column-side-column"> + <div class="actions-container"> + <h5>Actions</h5> + <form id="workspace-start-run-form" action="{{ startRunWorkspacePath .Workspace.ID }}" method="POST"> + <select name="strategy" id="start-run-strategy" onchange="this.form.submit()"> + <option value="" selected>-- start run --</option> + <option value="plan-only">plan only</option> + <option value="plan-and-apply">plan and apply</option> + </select> + </form> + </div> + <div class="terraform-version-container"><h5>Terraform Version</h5>v{{ .Workspace.TerraformVersion }}</div> + <div class="workspace-lock-container"> + <h5>Locking</h5> + {{ with .LockButton }} + <div class="workspace-lock workspace-lock-status-{{ .State }}"> + <span title="{{ .Tooltip }}">{{ title .State }}</span> + <form action="{{ .Action }}" method="POST"><button title="{{ .Tooltip }}" {{ disabled .Disabled }}>{{ .Text }}</button></form> + </div> + <span class="workspace-lock-info">{{ .Tooltip }}</span> + {{ end }} + </div> + {{ with .Workspace.Connection }} + <div>Connected to <span class="data">{{ .Repo }} ({{ $.VCSProvider.CloudConfig }})</span></div> + {{ end }} + <div> + <h5>Tags</h5> + <div class="workspace-tags-list"> + {{ range .Workspace.Tags }} + <span class="workspace-tag">{{ . }}</span> + {{ end }} + </div> + </div> + </div> + </div> {{ with .Workspace.LatestRun }} <script type="text/javascript"> fetch({{ widgetRunPath .ID }}) diff --git a/http/html/static/templates/content/workspace_list.tmpl b/http/html/static/templates/content/workspace_list.tmpl index d66dfb157..c0cf03a2c 100644 --- a/http/html/static/templates/content/workspace_list.tmpl +++ b/http/html/static/templates/content/workspace_list.tmpl @@ -12,6 +12,18 @@ {{ end }} {{ define "content" }} + <form id="tag-filter-form" action="{{ workspacesPath .Organization }}" method="GET"> + <div class="workspace-tags-list"> + {{ range $k, $v := .TagFilters }} + <div> + <input id="workspace-tag-filter-{{ $k }}" class="workspace-tag-filter-checkbox" name="search[tags]" value="{{ $k }}" type="checkbox" {{ checked $v }} onchange="this.form.submit()" /> + <label for="workspace-tag-filter-{{ $k }}" class="workspace-tag-filter-label"> + {{ $k }} + </label> + </div> + {{ end }} + </div> + </form> {{ template "content-list" . }} {{ end }} diff --git a/integration/tag_e2e_test.go b/integration/tag_e2e_test.go new file mode 100644 index 000000000..70ac8b4d8 --- /dev/null +++ b/integration/tag_e2e_test.go @@ -0,0 +1,109 @@ +package integration + +import ( + "fmt" + "testing" + "time" + + "github.com/chromedp/cdproto/input" + "github.com/chromedp/chromedp" + expect "github.com/google/goexpect" + "github.com/leg100/otf" + "github.com/leg100/otf/workspace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_TagsE2E demonstrates end-to-end usage of workspace tags. +func TestIntegration_TagsE2E(t *testing.T) { + t.Parallel() + + daemon := setup(t, nil) + user, ctx := daemon.createUserCtx(t, ctx) + org := daemon.createOrganization(t, ctx) + + // create a root module with a cloud backend configured to use workspaces + // with foo and bar tags. + root := createRootModule(t, fmt.Sprintf(` +terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + tags = ["foo", "bar"] + } + } +} +resource "null_resource" "tags_e2e" {} +`, daemon.Hostname(), org.Name)) + + // run terraform init + _, token := daemon.createToken(t, ctx, user) + e, tferr, err := expect.SpawnWithArgs( + []string{"terraform", "-chdir=" + root, "init", "-no-color"}, + time.Minute, + expect.PartialMatch(true), + expect.SetEnv( + append(envs, otf.CredentialEnv(daemon.Hostname(), token)), + ), + ) + require.NoError(t, err) + defer e.Close() + + // create tagged workspace when prompted + e.ExpectBatch([]expect.Batcher{ + &expect.BExp{R: "Enter a value: "}, + &expect.BSnd{S: "tagged\n"}, + &expect.BExp{R: "Terraform Cloud has been successfully initialized!"}, + }, time.Second*5) + // Terraform should return with exit code 0 + require.NoError(t, <-tferr, e.String) + + // confirm tagged workspace has been created + got, err := daemon.ListWorkspaces(ctx, workspace.ListOptions{ + Organization: otf.String(org.Name), + Tags: []string{"foo", "bar"}, + }) + require.NoError(t, err) + require.Equal(t, 1, len(got.Items)) + if assert.Equal(t, 2, len(got.Items[0].Tags)) { + assert.Contains(t, got.Items[0].Tags, "foo") + assert.Contains(t, got.Items[0].Tags, "bar") + } + + // test UI management of tags + browser := createBrowserCtx(t) + err = chromedp.Run(browser, chromedp.Tasks{ + newSession(t, ctx, daemon.Hostname(), user.Username, daemon.Secret), + chromedp.Navigate(workspaceURL(daemon.Hostname(), org.Name, "tagged")), + // confirm workspace page lists both tags + chromedp.WaitVisible(`//*[@class='workspace-tag'][contains(text(),'foo')]`), + chromedp.WaitVisible(`//*[@class='workspace-tag'][contains(text(),'bar')]`), + // go to workspace settings + chromedp.Click(`//a[text()='settings']`, chromedp.NodeVisible), + screenshot(t), + // remove bar tag + chromedp.Click(`//button[@id='button-remove-tag-bar']`, chromedp.NodeVisible), + screenshot(t), + matchText(t, ".flash-success", "removed tag: bar"), + // add new tag + chromedp.Focus("input#new-tag-name", chromedp.NodeVisible), + input.InsertText("baz"), + chromedp.Click(`//button[text()='Add new tag']`, chromedp.NodeVisible), + screenshot(t), + matchText(t, ".flash-success", "created tag: baz"), + // go to workspace listing + chromedp.Click(`//div[@class='container-header-title']//a[text()='workspaces']`, chromedp.NodeVisible), + screenshot(t), + // filter by tag foo + chromedp.Click(`//label[@for='workspace-tag-filter-foo']`, chromedp.NodeVisible), + screenshot(t), + // filter by tag bar + chromedp.Click(`//label[@for='workspace-tag-filter-baz']`, chromedp.NodeVisible), + screenshot(t), + // confirm workspace listing contains tagged workspace + chromedp.WaitVisible(`//div[@id='content-list']//a[text()='tagged']`, chromedp.NodeVisible), + }) + require.NoError(t, err) +} diff --git a/integration/tag_service_test.go b/integration/tag_service_test.go new file mode 100644 index 000000000..9ec4ee9e5 --- /dev/null +++ b/integration/tag_service_test.go @@ -0,0 +1,150 @@ +package integration + +import ( + "testing" + + "github.com/leg100/otf/workspace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIntegration_TagService(t *testing.T) { + t.Parallel() + + t.Run("add tags to workspace", func(t *testing.T) { + svc := setup(t, nil) + org := svc.createOrganization(t, ctx) + ws := svc.createWorkspace(t, ctx, org) + err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{ + {Name: "foo"}, + {Name: "bar"}, + {Name: "baz"}, + }) + require.NoError(t, err) + + // should have 3 tags across org + got, err := svc.ListTags(ctx, org.Name, workspace.ListTagsOptions{}) + require.NoError(t, err) + assert.Equal(t, 3, len(got.Items)) + + // should have 3 tags on ws + got, err = svc.ListWorkspaceTags(ctx, ws.ID, workspace.ListWorkspaceTagsOptions{}) + require.NoError(t, err) + assert.Equal(t, 3, len(got.Items)) + + t.Run("add same tags to another workspace", func(t *testing.T) { + ws := svc.createWorkspace(t, ctx, org) + err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{ + {Name: "foo"}, + {Name: "bar"}, + {Name: "baz"}, + }) + require.NoError(t, err) + + // should still have 3 tags across org + got, err := svc.ListTags(ctx, org.Name, workspace.ListTagsOptions{}) + require.NoError(t, err) + assert.Equal(t, 3, len(got.Items)) + + // should have 3 tags on ws + got, err = svc.ListWorkspaceTags(ctx, ws.ID, workspace.ListWorkspaceTagsOptions{}) + require.NoError(t, err) + assert.Equal(t, 3, len(got.Items)) + }) + + t.Run("invalid tag spec", func(t *testing.T) { + err = svc.AddTags(ctx, ws.ID, []workspace.TagSpec{{}}) + assert.Equal(t, workspace.ErrInvalidTagSpec, err) + }) + }) + + t.Run("remove tags from workspace", func(t *testing.T) { + svc := setup(t, nil) + ws := svc.createWorkspace(t, ctx, nil) + err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{ + {Name: "foo"}, + {Name: "bar"}, + {Name: "baz"}, + }) + require.NoError(t, err) + + got, err := svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{}) + require.NoError(t, err) + assert.Equal(t, 3, len(got.Items)) + + err = svc.RemoveTags(ctx, ws.ID, []workspace.TagSpec{ + {Name: "foo"}, + {Name: "doesnotexist"}, + {Name: "bar"}, + {Name: "baz"}, + {Name: "doesnotexist"}, + }) + require.NoError(t, err) + + got, err = svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{}) + require.NoError(t, err) + assert.Empty(t, got.Items) + }) + + t.Run("tag workspaces", func(t *testing.T) { + svc := setup(t, nil) + org := svc.createOrganization(t, ctx) + ws1 := svc.createWorkspace(t, ctx, org) + ws2 := svc.createWorkspace(t, ctx, org) + ws3 := svc.createWorkspace(t, ctx, org) + + // create tag first by adding tag to ws1 + err := svc.AddTags(ctx, ws1.ID, []workspace.TagSpec{{Name: "foo"}}) + require.NoError(t, err) + + // retrieve created tag + list, err := svc.ListTags(ctx, ws1.Organization, workspace.ListTagsOptions{}) + require.NoError(t, err) + require.Equal(t, 1, len(list.Items)) + tag := list.Items[0] + + // add tag to ws2 and ws3 + err = svc.TagWorkspaces(ctx, tag.ID, []string{ws2.ID, ws3.ID}) + require.NoError(t, err) + + // check ws2 is tagged + got, err := svc.ListWorkspaceTags(ctx, ws2.ID, workspace.ListWorkspaceTagsOptions{}) + require.NoError(t, err) + if assert.Equal(t, 1, len(got.Items)) { + assert.Equal(t, got.Items[0].Organization, ws2.Organization) + } + + // check ws3 is tagged + got, err = svc.ListWorkspaceTags(ctx, ws3.ID, workspace.ListWorkspaceTagsOptions{}) + require.NoError(t, err) + if assert.Equal(t, 1, len(got.Items)) { + assert.Equal(t, got.Items[0].Organization, ws3.Organization) + } + }) + + t.Run("delete tags from organization", func(t *testing.T) { + svc := setup(t, nil) + ws := svc.createWorkspace(t, ctx, nil) + err := svc.AddTags(ctx, ws.ID, []workspace.TagSpec{ + {Name: "foo"}, + {Name: "bar"}, + {Name: "baz"}, + }) + require.NoError(t, err) + + list, err := svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{}) + require.NoError(t, err) + require.Equal(t, 3, len(list.Items)) + + err = svc.DeleteTags(ctx, ws.Organization, []string{ + list.Items[0].ID, + list.Items[1].ID, + list.Items[2].ID, + }) + require.NoError(t, err) + + got, err := svc.ListTags(ctx, ws.Organization, workspace.ListTagsOptions{}) + require.NoError(t, err) + assert.Empty(t, got.Items) + }) +} diff --git a/integration/test_helpers.go b/integration/test_helpers.go index cf4233e5c..0ab120eb9 100644 --- a/integration/test_helpers.go +++ b/integration/test_helpers.go @@ -80,8 +80,12 @@ resource "null_resource" "e2e" {} config += cfg } + return createRootModule(t, config) +} + +func createRootModule(t *testing.T, tfconfig string) string { root := t.TempDir() - err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(config), 0o600) + err := os.WriteFile(filepath.Join(root, "main.tf"), []byte(tfconfig), 0o600) require.NoError(t, err) return root diff --git a/integration/workspace_test.go b/integration/workspace_test.go index 0076e7ac8..8b61a4ea1 100644 --- a/integration/workspace_test.go +++ b/integration/workspace_test.go @@ -1,7 +1,6 @@ package integration import ( - "context" "testing" "github.com/google/uuid" @@ -18,9 +17,6 @@ import ( func TestWorkspace(t *testing.T) { t.Parallel() - // perform all actions as superuser - ctx := otf.AddSubjectToContext(context.Background(), &auth.SiteAdmin) - t.Run("create", func(t *testing.T) { svc := setup(t, nil) org := svc.createOrganization(t, ctx) @@ -233,6 +229,58 @@ func TestWorkspace(t *testing.T) { } }) + t.Run("list by tag", func(t *testing.T) { + svc := setup(t, nil) + org := svc.createOrganization(t, ctx) + ws1, err := svc.CreateWorkspace(ctx, workspace.CreateOptions{ + Name: otf.String(uuid.NewString()), + Organization: &org.Name, + Tags: []workspace.TagSpec{{Name: "foo"}}, + }) + require.NoError(t, err) + ws2, err := svc.CreateWorkspace(ctx, workspace.CreateOptions{ + Name: otf.String(uuid.NewString()), + Organization: &org.Name, + Tags: []workspace.TagSpec{{Name: "foo"}, {Name: "bar"}}, + }) + require.NoError(t, err) + + tests := []struct { + name string + tags []string + want func(*testing.T, *workspace.WorkspaceList) + }{ + { + name: "foo", + tags: []string{"foo"}, + want: func(t *testing.T, l *workspace.WorkspaceList) { + assert.Equal(t, 2, len(l.Items)) + assert.Contains(t, l.Items, ws1) + assert.Contains(t, l.Items, ws2) + }, + }, + { + name: "foo and bar", + tags: []string{"foo", "bar"}, + want: func(t *testing.T, l *workspace.WorkspaceList) { + assert.Equal(t, 1, len(l.Items)) + assert.Contains(t, l.Items, ws2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, err := svc.ListWorkspaces(ctx, workspace.ListOptions{ + Tags: tt.tags, + }) + require.NoError(t, err) + + tt.want(t, results) + }) + } + }) + t.Run("list by user", func(t *testing.T) { svc := setup(t, nil) org := svc.createOrganization(t, ctx) diff --git a/organization/db.go b/organization/db.go index f0ed7fbc2..e93cd4c7e 100644 --- a/organization/db.go +++ b/organization/db.go @@ -98,7 +98,7 @@ func (db *pgdb) list(ctx context.Context, opts OrganizationListOptions) (*Organi return &OrganizationList{ Items: items, - Pagination: otf.NewPagination(opts.ListOptions, *count), + Pagination: otf.NewPagination(opts.ListOptions, count), }, nil } diff --git a/otf.go b/otf.go index dd73ead53..1c8f81dce 100644 --- a/otf.go +++ b/otf.go @@ -201,3 +201,18 @@ func Index[E comparable](s []E, v E) int { func Contains[E comparable](s []E, v E) bool { return Index(s, v) >= 0 } + +// DiffStrings returns the elements in `a` that aren't in `b`. +func DiffStrings(a, b []string) []string { + mb := make(map[string]struct{}, len(b)) + for _, x := range b { + mb[x] = struct{}{} + } + var diff []string + for _, x := range a { + if _, found := mb[x]; !found { + diff = append(diff, x) + } + } + return diff +} diff --git a/policy/policy.go b/policy/policy.go deleted file mode 100644 index aa03f7675..000000000 --- a/policy/policy.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package policy policies access to workspaces and manages workspace -// permissions -package policy diff --git a/policy/service.go b/policy/service.go deleted file mode 100644 index abcc66cef..000000000 --- a/policy/service.go +++ /dev/null @@ -1,92 +0,0 @@ -package policy - -import ( - "context" - - "github.com/leg100/otf" - "github.com/leg100/otf/logr" - "github.com/leg100/otf/rbac" -) - -type ( - PolicyService = Service - - Service interface { - GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) - SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error - UnsetPermission(ctx context.Context, workspaceID, team string) error - } - - service struct { - logr.Logger - db *pgdb - } - - Options struct { - otf.DB - logr.Logger - } -) - -func NewService(opts Options) *service { - return &service{ - Logger: opts.Logger, - db: &pgdb{opts.DB}, - } -} - -// GetPolicy retrieves a workspace policy. -// -// NOTE: no authz protects this endpoint because it's used in the process of making -// authz decisions. -func (s *service) GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) { - return s.db.getWorkspacePolicy(ctx, workspaceID) -} - -func (s *service) SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error { - subject, err := s.CanAccess(ctx, rbac.SetWorkspacePermissionAction, workspaceID) - if err != nil { - return err - } - - if err := s.db.setWorkspacePermission(ctx, workspaceID, team, role); err != nil { - s.Error(err, "setting workspace permission", "subject", subject, "workspace", workspaceID) - return err - } - - s.V(0).Info("set workspace permission", "team", team, "role", role, "subject", subject, "workspace", workspaceID) - - // TODO: publish event - - return nil -} - -func (s *service) UnsetPermission(ctx context.Context, workspaceID, team string) error { - subject, err := s.CanAccess(ctx, rbac.UnsetWorkspacePermissionAction, workspaceID) - if err != nil { - s.Error(err, "unsetting workspace permission", "team", team, "subject", subject, "workspace", workspaceID) - return err - } - - s.V(0).Info("unset workspace permission", "team", team, "subject", subject, "workspace", workspaceID) - // TODO: publish event - return s.db.unsetWorkspacePermission(ctx, workspaceID, team) -} - -// CanAccess determines whether the subject (in the ctx) is permitted to carry -// out the specified action on the workspace with the given id. -func (s *service) CanAccess(ctx context.Context, action rbac.Action, workspaceID string) (otf.Subject, error) { - subj, err := otf.SubjectFromContext(ctx) - if err != nil { - return nil, err - } - policy, err := s.db.getWorkspacePolicy(ctx, workspaceID) - if err != nil { - return nil, otf.ErrResourceNotFound - } - if subj.CanAccessWorkspace(action, policy) { - return subj, nil - } - s.Error(nil, "unauthorized action", "workspace", workspaceID, "organization", policy.Organization, "action", action, "subject", subj) - return nil, otf.ErrAccessNotPermitted -} diff --git a/rbac/action.go b/rbac/action.go index 4aad0cce2..d9713a540 100644 --- a/rbac/action.go +++ b/rbac/action.go @@ -65,6 +65,13 @@ const ( UnsetWorkspacePermissionAction UpdateWorkspaceAction + ListTagsAction + DeleteTagsAction + TagWorkspacesAction + AddTagsAction + RemoveTagsAction + ListWorkspaceTags + LockWorkspaceAction UnlockWorkspaceAction ForceUnlockWorkspaceAction diff --git a/rbac/action_string.go b/rbac/action_string.go index 37a14564a..d730b3824 100644 --- a/rbac/action_string.go +++ b/rbac/action_string.go @@ -58,37 +58,43 @@ func _() { _ = x[SetWorkspacePermissionAction-47] _ = x[UnsetWorkspacePermissionAction-48] _ = x[UpdateWorkspaceAction-49] - _ = x[LockWorkspaceAction-50] - _ = x[UnlockWorkspaceAction-51] - _ = x[ForceUnlockWorkspaceAction-52] - _ = x[CreateStateVersionAction-53] - _ = x[ListStateVersionsAction-54] - _ = x[GetStateVersionAction-55] - _ = x[DeleteStateVersionAction-56] - _ = x[RollbackStateVersionAction-57] - _ = x[DownloadStateAction-58] - _ = x[GetStateVersionOutputAction-59] - _ = x[CreateConfigurationVersionAction-60] - _ = x[ListConfigurationVersionsAction-61] - _ = x[GetConfigurationVersionAction-62] - _ = x[DownloadConfigurationVersionAction-63] - _ = x[DeleteConfigurationVersionAction-64] - _ = x[CreateUserAction-65] - _ = x[ListUsersAction-66] - _ = x[GetUserAction-67] - _ = x[DeleteUserAction-68] - _ = x[CreateTeamAction-69] - _ = x[UpdateTeamAction-70] - _ = x[GetTeamAction-71] - _ = x[ListTeamsAction-72] - _ = x[DeleteTeamAction-73] - _ = x[AddTeamMembershipAction-74] - _ = x[RemoveTeamMembershipAction-75] + _ = x[ListTagsAction-50] + _ = x[DeleteTagsAction-51] + _ = x[TagWorkspacesAction-52] + _ = x[AddTagsAction-53] + _ = x[RemoveTagsAction-54] + _ = x[ListWorkspaceTags-55] + _ = x[LockWorkspaceAction-56] + _ = x[UnlockWorkspaceAction-57] + _ = x[ForceUnlockWorkspaceAction-58] + _ = x[CreateStateVersionAction-59] + _ = x[ListStateVersionsAction-60] + _ = x[GetStateVersionAction-61] + _ = x[DeleteStateVersionAction-62] + _ = x[RollbackStateVersionAction-63] + _ = x[DownloadStateAction-64] + _ = x[GetStateVersionOutputAction-65] + _ = x[CreateConfigurationVersionAction-66] + _ = x[ListConfigurationVersionsAction-67] + _ = x[GetConfigurationVersionAction-68] + _ = x[DownloadConfigurationVersionAction-69] + _ = x[DeleteConfigurationVersionAction-70] + _ = x[CreateUserAction-71] + _ = x[ListUsersAction-72] + _ = x[GetUserAction-73] + _ = x[DeleteUserAction-74] + _ = x[CreateTeamAction-75] + _ = x[UpdateTeamAction-76] + _ = x[GetTeamAction-77] + _ = x[ListTeamsAction-78] + _ = x[DeleteTeamAction-79] + _ = x[AddTeamMembershipAction-80] + _ = x[RemoveTeamMembershipAction-81] } -const _Action_name = "WatchActionCreateOrganizationActionUpdateOrganizationActionGetOrganizationActionListOrganizationsActionGetEntitlementsActionDeleteOrganizationActionCreateVCSProviderActionGetVCSProviderActionListVCSProvidersActionDeleteVCSProviderActionCreateAgentTokenActionListAgentTokensActionDeleteAgentTokenActionCreateRunTokenActionCreateModuleActionCreateModuleVersionActionUpdateModuleActionListModulesActionGetModuleActionDeleteModuleActionDeleteModuleVersionActionCreateVariableActionUpdateVariableActionListVariablesActionGetVariableActionDeleteVariableActionGetRunActionListRunsActionApplyRunActionCreateRunActionDiscardRunActionDeleteRunActionCancelRunActionEnqueuePlanActionStartPhaseActionFinishPhaseActionPutChunkActionTailLogsActionGetPlanFileActionUploadPlanFileActionGetLockFileActionUploadLockFileActionListWorkspacesActionGetWorkspaceActionCreateWorkspaceActionDeleteWorkspaceActionSetWorkspacePermissionActionUnsetWorkspacePermissionActionUpdateWorkspaceActionLockWorkspaceActionUnlockWorkspaceActionForceUnlockWorkspaceActionCreateStateVersionActionListStateVersionsActionGetStateVersionActionDeleteStateVersionActionRollbackStateVersionActionDownloadStateActionGetStateVersionOutputActionCreateConfigurationVersionActionListConfigurationVersionsActionGetConfigurationVersionActionDownloadConfigurationVersionActionDeleteConfigurationVersionActionCreateUserActionListUsersActionGetUserActionDeleteUserActionCreateTeamActionUpdateTeamActionGetTeamActionListTeamsActionDeleteTeamActionAddTeamMembershipActionRemoveTeamMembershipAction" +const _Action_name = "WatchActionCreateOrganizationActionUpdateOrganizationActionGetOrganizationActionListOrganizationsActionGetEntitlementsActionDeleteOrganizationActionCreateVCSProviderActionGetVCSProviderActionListVCSProvidersActionDeleteVCSProviderActionCreateAgentTokenActionListAgentTokensActionDeleteAgentTokenActionCreateRunTokenActionCreateModuleActionCreateModuleVersionActionUpdateModuleActionListModulesActionGetModuleActionDeleteModuleActionDeleteModuleVersionActionCreateVariableActionUpdateVariableActionListVariablesActionGetVariableActionDeleteVariableActionGetRunActionListRunsActionApplyRunActionCreateRunActionDiscardRunActionDeleteRunActionCancelRunActionEnqueuePlanActionStartPhaseActionFinishPhaseActionPutChunkActionTailLogsActionGetPlanFileActionUploadPlanFileActionGetLockFileActionUploadLockFileActionListWorkspacesActionGetWorkspaceActionCreateWorkspaceActionDeleteWorkspaceActionSetWorkspacePermissionActionUnsetWorkspacePermissionActionUpdateWorkspaceActionListTagsActionDeleteTagsActionTagWorkspacesActionAddTagsActionRemoveTagsActionListWorkspaceTagsLockWorkspaceActionUnlockWorkspaceActionForceUnlockWorkspaceActionCreateStateVersionActionListStateVersionsActionGetStateVersionActionDeleteStateVersionActionRollbackStateVersionActionDownloadStateActionGetStateVersionOutputActionCreateConfigurationVersionActionListConfigurationVersionsActionGetConfigurationVersionActionDownloadConfigurationVersionActionDeleteConfigurationVersionActionCreateUserActionListUsersActionGetUserActionDeleteUserActionCreateTeamActionUpdateTeamActionGetTeamActionListTeamsActionDeleteTeamActionAddTeamMembershipActionRemoveTeamMembershipAction" -var _Action_index = [...]uint16{0, 11, 35, 59, 80, 103, 124, 148, 171, 191, 213, 236, 258, 279, 301, 321, 339, 364, 382, 399, 414, 432, 457, 477, 497, 516, 533, 553, 565, 579, 593, 608, 624, 639, 654, 671, 687, 704, 718, 732, 749, 769, 786, 806, 826, 844, 865, 886, 914, 944, 965, 984, 1005, 1031, 1055, 1078, 1099, 1123, 1149, 1168, 1195, 1227, 1258, 1287, 1321, 1353, 1369, 1384, 1397, 1413, 1429, 1445, 1458, 1473, 1489, 1512, 1538} +var _Action_index = [...]uint16{0, 11, 35, 59, 80, 103, 124, 148, 171, 191, 213, 236, 258, 279, 301, 321, 339, 364, 382, 399, 414, 432, 457, 477, 497, 516, 533, 553, 565, 579, 593, 608, 624, 639, 654, 671, 687, 704, 718, 732, 749, 769, 786, 806, 826, 844, 865, 886, 914, 944, 965, 979, 995, 1014, 1027, 1043, 1060, 1079, 1100, 1126, 1150, 1173, 1194, 1218, 1244, 1263, 1290, 1322, 1353, 1382, 1416, 1448, 1464, 1479, 1492, 1508, 1524, 1540, 1553, 1568, 1584, 1607, 1633} func (i Action) String() string { if i < 0 || i >= Action(len(_Action_index)-1) { diff --git a/rbac/role.go b/rbac/role.go index 35fc3251d..c020bef06 100644 --- a/rbac/role.go +++ b/rbac/role.go @@ -16,6 +16,7 @@ var ( GetTeamAction: true, ListTeamsAction: true, ListUsersAction: true, + ListTagsAction: true, }, } @@ -35,6 +36,7 @@ var ( ListVariablesAction: true, GetVariableAction: true, WatchAction: true, + ListWorkspaceTags: true, }, } @@ -86,6 +88,8 @@ var ( CreateWorkspaceAction: true, ListWorkspacesAction: true, UpdateWorkspaceAction: true, + AddTagsAction: true, + RemoveTagsAction: true, // includes WorkspaceAdminRole perms too (see below) }, } diff --git a/repo/db.go b/repo/db.go index 898981dfe..009e1899e 100644 --- a/repo/db.go +++ b/repo/db.go @@ -110,7 +110,7 @@ func (db *pgdb) deleteConnection(ctx context.Context, opts DisconnectOptions) (h } } -func (db *pgdb) countConnections(ctx context.Context, hookID uuid.UUID) (*int, error) { +func (db *pgdb) countConnections(ctx context.Context, hookID uuid.UUID) (int, error) { return db.CountRepoConnectionsByID(ctx, sql.UUID(hookID)) } @@ -126,8 +126,7 @@ func (db *pgdb) deleteHook(ctx context.Context, id uuid.UUID) (*hook, error) { // caller can use the transaction. func (db *pgdb) lock(ctx context.Context, callback func(*pgdb) error) error { return db.Tx(ctx, func(tx otf.DB) error { - _, err := tx.Exec(ctx, "LOCK webhooks") - if err != nil { + if _, err := tx.Exec(ctx, "LOCK webhooks"); err != nil { return err } return callback(newPGDB(tx, db.factory)) diff --git a/repo/service.go b/repo/service.go index a492260ef..beb734aa8 100644 --- a/repo/service.go +++ b/repo/service.go @@ -148,7 +148,7 @@ func (s *service) Disconnect(ctx context.Context, opts DisconnectOptions) error if err != nil { return err } - if *conns > 0 { + if conns > 0 { return nil } diff --git a/run/db.go b/run/db.go index 132295650..82279704f 100644 --- a/run/db.go +++ b/run/db.go @@ -183,9 +183,9 @@ func (db *pgdb) CreateApplyReport(ctx context.Context, runID string, report Reso func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, error) { batch := &pgx.Batch{} - organizationName := "%" + organization := "%" if opts.Organization != nil { - organizationName = *opts.Organization + organization = *opts.Organization } workspaceName := "%" if opts.WorkspaceName != nil { @@ -204,7 +204,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er speculative = strconv.FormatBool(*opts.Speculative) } db.FindRunsBatch(batch, pggen.FindRunsParams{ - OrganizationNames: []string{organizationName}, + OrganizationNames: []string{organization}, WorkspaceNames: []string{workspaceName}, WorkspaceIds: []string{workspaceID}, Statuses: statuses, @@ -213,7 +213,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er Offset: opts.GetOffset(), }) db.CountRunsBatch(batch, pggen.CountRunsParams{ - OrganizationNames: []string{organizationName}, + OrganizationNames: []string{organization}, WorkspaceNames: []string{workspaceName}, WorkspaceIds: []string{workspaceID}, Statuses: statuses, @@ -239,7 +239,7 @@ func (db *pgdb) ListRuns(ctx context.Context, opts RunListOptions) (*RunList, er return &RunList{ Items: items, - Pagination: otf.NewPagination(opts.ListOptions, *count), + Pagination: otf.NewPagination(opts.ListOptions, count), }, nil } diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index 55b4e5180..996e39798 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -171,7 +171,7 @@ func (s *scheduler) reinitialize(ctx context.Context) error { q, ok := s.queues[payload.WorkspaceID] if !ok { // should never happen - s.Error(fmt.Errorf("workspace queue does not exist for run event"), "workspace", payload.WorkspaceID, "run", payload.ID) + s.Error(nil, "workspace queue does not exist for run event", "workspace", payload.WorkspaceID, "run", payload.ID, "event", event.Type) continue } if err := q.handleEvent(ctx, event); err != nil { diff --git a/sql/migrations/20230427144312_add_table_workspace_tags.sql b/sql/migrations/20230427144312_add_table_workspace_tags.sql new file mode 100644 index 000000000..a2f1ab103 --- /dev/null +++ b/sql/migrations/20230427144312_add_table_workspace_tags.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS tags ( + tag_id TEXT NOT NULL, + name TEXT NOT NULL, + organization_name TEXT REFERENCES organizations (name) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + PRIMARY KEY (tag_id), + UNIQUE (organization_name, name) +); + +CREATE TABLE IF NOT EXISTS workspace_tags ( + tag_id TEXT REFERENCES tags ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + workspace_id TEXT REFERENCES workspaces ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + UNIQUE (tag_id, workspace_id) +); + +-- +goose Down +DROP TABLE IF EXISTS workspace_tags; +DROP TABLE IF EXISTS tags; diff --git a/sql/pggen/agent_token.sql.go b/sql/pggen/agent_token.sql.go index f91a19c74..6b00e9972 100644 --- a/sql/pggen/agent_token.sql.go +++ b/sql/pggen/agent_token.sql.go @@ -90,12 +90,12 @@ type Querier interface { // FindConfigurationVersionsByWorkspaceIDScan scans the result of an executed FindConfigurationVersionsByWorkspaceIDBatch query. FindConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) ([]FindConfigurationVersionsByWorkspaceIDRow, error) - CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (*int, error) + CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (int, error) // CountConfigurationVersionsByWorkspaceIDBatch enqueues a CountConfigurationVersionsByWorkspaceID query into batch to be executed // later by the batch. CountConfigurationVersionsByWorkspaceIDBatch(batch genericBatch, workspaceID pgtype.Text) // CountConfigurationVersionsByWorkspaceIDScan scans the result of an executed CountConfigurationVersionsByWorkspaceIDBatch query. - CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (*int, error) + CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (int, error) // FindConfigurationVersionByID finds a configuration_version by its id. // @@ -284,12 +284,12 @@ type Querier interface { // FindOrganizationsScan scans the result of an executed FindOrganizationsBatch query. FindOrganizationsScan(results pgx.BatchResults) ([]FindOrganizationsRow, error) - CountOrganizations(ctx context.Context, names []string) (*int, error) + CountOrganizations(ctx context.Context, names []string) (int, error) // CountOrganizationsBatch enqueues a CountOrganizations query into batch to be executed // later by the batch. CountOrganizationsBatch(batch genericBatch, names []string) // CountOrganizationsScan scans the result of an executed CountOrganizationsBatch query. - CountOrganizationsScan(results pgx.BatchResults) (*int, error) + CountOrganizationsScan(results pgx.BatchResults) (int, error) InsertOrganization(ctx context.Context, params InsertOrganizationParams) (pgconn.CommandTag, error) // InsertOrganizationBatch enqueues a InsertOrganization query into batch to be executed @@ -398,12 +398,12 @@ type Querier interface { // InsertRepoConnectionScan scans the result of an executed InsertRepoConnectionBatch query. InsertRepoConnectionScan(results pgx.BatchResults) (pgconn.CommandTag, error) - CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (*int, error) + CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (int, error) // CountRepoConnectionsByIDBatch enqueues a CountRepoConnectionsByID query into batch to be executed // later by the batch. CountRepoConnectionsByIDBatch(batch genericBatch, webhookID pgtype.UUID) // CountRepoConnectionsByIDScan scans the result of an executed CountRepoConnectionsByIDBatch query. - CountRepoConnectionsByIDScan(results pgx.BatchResults) (*int, error) + CountRepoConnectionsByIDScan(results pgx.BatchResults) (int, error) DeleteWorkspaceConnectionByID(ctx context.Context, workspaceID pgtype.Text) (DeleteWorkspaceConnectionByIDRow, error) // DeleteWorkspaceConnectionByIDBatch enqueues a DeleteWorkspaceConnectionByID query into batch to be executed @@ -440,12 +440,12 @@ type Querier interface { // FindRunsScan scans the result of an executed FindRunsBatch query. FindRunsScan(results pgx.BatchResults) ([]FindRunsRow, error) - CountRuns(ctx context.Context, params CountRunsParams) (*int, error) + CountRuns(ctx context.Context, params CountRunsParams) (int, error) // CountRunsBatch enqueues a CountRuns query into batch to be executed // later by the batch. CountRunsBatch(batch genericBatch, params CountRunsParams) // CountRunsScan scans the result of an executed CountRunsBatch query. - CountRunsScan(results pgx.BatchResults) (*int, error) + CountRunsScan(results pgx.BatchResults) (int, error) FindRunByID(ctx context.Context, runID pgtype.Text) (FindRunByIDRow, error) // FindRunByIDBatch enqueues a FindRunByID query into batch to be executed @@ -510,12 +510,12 @@ type Querier interface { // FindStateVersionsByWorkspaceNameScan scans the result of an executed FindStateVersionsByWorkspaceNameBatch query. FindStateVersionsByWorkspaceNameScan(results pgx.BatchResults) ([]FindStateVersionsByWorkspaceNameRow, error) - CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (*int, error) + CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (int, error) // CountStateVersionsByWorkspaceNameBatch enqueues a CountStateVersionsByWorkspaceName query into batch to be executed // later by the batch. CountStateVersionsByWorkspaceNameBatch(batch genericBatch, workspaceName pgtype.Text, organizationName pgtype.Text) // CountStateVersionsByWorkspaceNameScan scans the result of an executed CountStateVersionsByWorkspaceNameBatch query. - CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (*int, error) + CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (int, error) FindStateVersionByID(ctx context.Context, id pgtype.Text) (FindStateVersionByIDRow, error) // FindStateVersionByIDBatch enqueues a FindStateVersionByID query into batch to be executed @@ -559,6 +559,83 @@ type Querier interface { // FindStateVersionOutputByIDScan scans the result of an executed FindStateVersionOutputByIDBatch query. FindStateVersionOutputByIDScan(results pgx.BatchResults) (FindStateVersionOutputByIDRow, error) + InsertTag(ctx context.Context, params InsertTagParams) (pgconn.CommandTag, error) + // InsertTagBatch enqueues a InsertTag query into batch to be executed + // later by the batch. + InsertTagBatch(batch genericBatch, params InsertTagParams) + // InsertTagScan scans the result of an executed InsertTagBatch query. + InsertTagScan(results pgx.BatchResults) (pgconn.CommandTag, error) + + InsertWorkspaceTag(ctx context.Context, tagID pgtype.Text, workspaceID pgtype.Text) (pgtype.Text, error) + // InsertWorkspaceTagBatch enqueues a InsertWorkspaceTag query into batch to be executed + // later by the batch. + InsertWorkspaceTagBatch(batch genericBatch, tagID pgtype.Text, workspaceID pgtype.Text) + // InsertWorkspaceTagScan scans the result of an executed InsertWorkspaceTagBatch query. + InsertWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error) + + InsertWorkspaceTagByName(ctx context.Context, workspaceID pgtype.Text, tagName pgtype.Text) (pgtype.Text, error) + // InsertWorkspaceTagByNameBatch enqueues a InsertWorkspaceTagByName query into batch to be executed + // later by the batch. + InsertWorkspaceTagByNameBatch(batch genericBatch, workspaceID pgtype.Text, tagName pgtype.Text) + // InsertWorkspaceTagByNameScan scans the result of an executed InsertWorkspaceTagByNameBatch query. + InsertWorkspaceTagByNameScan(results pgx.BatchResults) (pgtype.Text, error) + + FindTags(ctx context.Context, params FindTagsParams) ([]FindTagsRow, error) + // FindTagsBatch enqueues a FindTags query into batch to be executed + // later by the batch. + FindTagsBatch(batch genericBatch, params FindTagsParams) + // FindTagsScan scans the result of an executed FindTagsBatch query. + FindTagsScan(results pgx.BatchResults) ([]FindTagsRow, error) + + FindWorkspaceTags(ctx context.Context, params FindWorkspaceTagsParams) ([]FindWorkspaceTagsRow, error) + // FindWorkspaceTagsBatch enqueues a FindWorkspaceTags query into batch to be executed + // later by the batch. + FindWorkspaceTagsBatch(batch genericBatch, params FindWorkspaceTagsParams) + // FindWorkspaceTagsScan scans the result of an executed FindWorkspaceTagsBatch query. + FindWorkspaceTagsScan(results pgx.BatchResults) ([]FindWorkspaceTagsRow, error) + + FindTagByName(ctx context.Context, name pgtype.Text, organizationName pgtype.Text) (FindTagByNameRow, error) + // FindTagByNameBatch enqueues a FindTagByName query into batch to be executed + // later by the batch. + FindTagByNameBatch(batch genericBatch, name pgtype.Text, organizationName pgtype.Text) + // FindTagByNameScan scans the result of an executed FindTagByNameBatch query. + FindTagByNameScan(results pgx.BatchResults) (FindTagByNameRow, error) + + FindTagByID(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (FindTagByIDRow, error) + // FindTagByIDBatch enqueues a FindTagByID query into batch to be executed + // later by the batch. + FindTagByIDBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text) + // FindTagByIDScan scans the result of an executed FindTagByIDBatch query. + FindTagByIDScan(results pgx.BatchResults) (FindTagByIDRow, error) + + CountTags(ctx context.Context, organizationName pgtype.Text) (int, error) + // CountTagsBatch enqueues a CountTags query into batch to be executed + // later by the batch. + CountTagsBatch(batch genericBatch, organizationName pgtype.Text) + // CountTagsScan scans the result of an executed CountTagsBatch query. + CountTagsScan(results pgx.BatchResults) (int, error) + + CountWorkspaceTags(ctx context.Context, workspaceID pgtype.Text) (int, error) + // CountWorkspaceTagsBatch enqueues a CountWorkspaceTags query into batch to be executed + // later by the batch. + CountWorkspaceTagsBatch(batch genericBatch, workspaceID pgtype.Text) + // CountWorkspaceTagsScan scans the result of an executed CountWorkspaceTagsBatch query. + CountWorkspaceTagsScan(results pgx.BatchResults) (int, error) + + DeleteTag(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (pgtype.Text, error) + // DeleteTagBatch enqueues a DeleteTag query into batch to be executed + // later by the batch. + DeleteTagBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text) + // DeleteTagScan scans the result of an executed DeleteTagBatch query. + DeleteTagScan(results pgx.BatchResults) (pgtype.Text, error) + + DeleteWorkspaceTag(ctx context.Context, workspaceID pgtype.Text, tagID pgtype.Text) (pgtype.Text, error) + // DeleteWorkspaceTagBatch enqueues a DeleteWorkspaceTag query into batch to be executed + // later by the batch. + DeleteWorkspaceTagBatch(batch genericBatch, workspaceID pgtype.Text, tagID pgtype.Text) + // DeleteWorkspaceTagScan scans the result of an executed DeleteWorkspaceTagBatch query. + DeleteWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error) + InsertTeam(ctx context.Context, params InsertTeamParams) (pgconn.CommandTag, error) // InsertTeamBatch enqueues a InsertTeam query into batch to be executed // later by the batch. @@ -846,12 +923,12 @@ type Querier interface { // FindWorkspacesScan scans the result of an executed FindWorkspacesBatch query. FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspacesRow, error) - CountWorkspaces(ctx context.Context, prefix pgtype.Text, organizationNames []string) (*int, error) + CountWorkspaces(ctx context.Context, params CountWorkspacesParams) (int, error) // CountWorkspacesBatch enqueues a CountWorkspaces query into batch to be executed // later by the batch. - CountWorkspacesBatch(batch genericBatch, prefix pgtype.Text, organizationNames []string) + CountWorkspacesBatch(batch genericBatch, params CountWorkspacesParams) // CountWorkspacesScan scans the result of an executed CountWorkspacesBatch query. - CountWorkspacesScan(results pgx.BatchResults) (*int, error) + CountWorkspacesScan(results pgx.BatchResults) (int, error) FindWorkspacesByWebhookID(ctx context.Context, webhookID pgtype.UUID) ([]FindWorkspacesByWebhookIDRow, error) // FindWorkspacesByWebhookIDBatch enqueues a FindWorkspacesByWebhookID query into batch to be executed @@ -867,12 +944,12 @@ type Querier interface { // FindWorkspacesByUsernameScan scans the result of an executed FindWorkspacesByUsernameBatch query. FindWorkspacesByUsernameScan(results pgx.BatchResults) ([]FindWorkspacesByUsernameRow, error) - CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (*int, error) + CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (int, error) // CountWorkspacesByUsernameBatch enqueues a CountWorkspacesByUsername query into batch to be executed // later by the batch. CountWorkspacesByUsernameBatch(batch genericBatch, organizationName pgtype.Text, username pgtype.Text) // CountWorkspacesByUsernameScan scans the result of an executed CountWorkspacesByUsernameBatch query. - CountWorkspacesByUsernameScan(results pgx.BatchResults) (*int, error) + CountWorkspacesByUsernameScan(results pgx.BatchResults) (int, error) FindWorkspaceByName(ctx context.Context, name pgtype.Text, organizationName pgtype.Text) (FindWorkspaceByNameRow, error) // FindWorkspaceByNameBatch enqueues a FindWorkspaceByName query into batch to be executed @@ -937,12 +1014,12 @@ type Querier interface { // UpsertWorkspacePermissionScan scans the result of an executed UpsertWorkspacePermissionBatch query. UpsertWorkspacePermissionScan(results pgx.BatchResults) (pgconn.CommandTag, error) - FindWorkspacePermissions(ctx context.Context, workspaceID pgtype.Text) ([]FindWorkspacePermissionsRow, error) - // FindWorkspacePermissionsBatch enqueues a FindWorkspacePermissions query into batch to be executed + FindWorkspacePolicyByID(ctx context.Context, workspaceID pgtype.Text) (FindWorkspacePolicyByIDRow, error) + // FindWorkspacePolicyByIDBatch enqueues a FindWorkspacePolicyByID query into batch to be executed // later by the batch. - FindWorkspacePermissionsBatch(batch genericBatch, workspaceID pgtype.Text) - // FindWorkspacePermissionsScan scans the result of an executed FindWorkspacePermissionsBatch query. - FindWorkspacePermissionsScan(results pgx.BatchResults) ([]FindWorkspacePermissionsRow, error) + FindWorkspacePolicyByIDBatch(batch genericBatch, workspaceID pgtype.Text) + // FindWorkspacePolicyByIDScan scans the result of an executed FindWorkspacePolicyByIDBatch query. + FindWorkspacePolicyByIDScan(results pgx.BatchResults) (FindWorkspacePolicyByIDRow, error) DeleteWorkspacePermissionByID(ctx context.Context, workspaceID pgtype.Text, teamName pgtype.Text) (pgconn.CommandTag, error) // DeleteWorkspacePermissionByIDBatch enqueues a DeleteWorkspacePermissionByID query into batch to be executed @@ -1255,6 +1332,39 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, findStateVersionOutputByIDSQL, findStateVersionOutputByIDSQL); err != nil { return fmt.Errorf("prepare query 'FindStateVersionOutputByID': %w", err) } + if _, err := p.Prepare(ctx, insertTagSQL, insertTagSQL); err != nil { + return fmt.Errorf("prepare query 'InsertTag': %w", err) + } + if _, err := p.Prepare(ctx, insertWorkspaceTagSQL, insertWorkspaceTagSQL); err != nil { + return fmt.Errorf("prepare query 'InsertWorkspaceTag': %w", err) + } + if _, err := p.Prepare(ctx, insertWorkspaceTagByNameSQL, insertWorkspaceTagByNameSQL); err != nil { + return fmt.Errorf("prepare query 'InsertWorkspaceTagByName': %w", err) + } + if _, err := p.Prepare(ctx, findTagsSQL, findTagsSQL); err != nil { + return fmt.Errorf("prepare query 'FindTags': %w", err) + } + if _, err := p.Prepare(ctx, findWorkspaceTagsSQL, findWorkspaceTagsSQL); err != nil { + return fmt.Errorf("prepare query 'FindWorkspaceTags': %w", err) + } + if _, err := p.Prepare(ctx, findTagByNameSQL, findTagByNameSQL); err != nil { + return fmt.Errorf("prepare query 'FindTagByName': %w", err) + } + if _, err := p.Prepare(ctx, findTagByIDSQL, findTagByIDSQL); err != nil { + return fmt.Errorf("prepare query 'FindTagByID': %w", err) + } + if _, err := p.Prepare(ctx, countTagsSQL, countTagsSQL); err != nil { + return fmt.Errorf("prepare query 'CountTags': %w", err) + } + if _, err := p.Prepare(ctx, countWorkspaceTagsSQL, countWorkspaceTagsSQL); err != nil { + return fmt.Errorf("prepare query 'CountWorkspaceTags': %w", err) + } + if _, err := p.Prepare(ctx, deleteTagSQL, deleteTagSQL); err != nil { + return fmt.Errorf("prepare query 'DeleteTag': %w", err) + } + if _, err := p.Prepare(ctx, deleteWorkspaceTagSQL, deleteWorkspaceTagSQL); err != nil { + return fmt.Errorf("prepare query 'DeleteWorkspaceTag': %w", err) + } if _, err := p.Prepare(ctx, insertTeamSQL, insertTeamSQL); err != nil { return fmt.Errorf("prepare query 'InsertTeam': %w", err) } @@ -1417,8 +1527,8 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, upsertWorkspacePermissionSQL, upsertWorkspacePermissionSQL); err != nil { return fmt.Errorf("prepare query 'UpsertWorkspacePermission': %w", err) } - if _, err := p.Prepare(ctx, findWorkspacePermissionsSQL, findWorkspacePermissionsSQL); err != nil { - return fmt.Errorf("prepare query 'FindWorkspacePermissions': %w", err) + if _, err := p.Prepare(ctx, findWorkspacePolicyByIDSQL, findWorkspacePolicyByIDSQL); err != nil { + return fmt.Errorf("prepare query 'FindWorkspacePolicyByID': %w", err) } if _, err := p.Prepare(ctx, deleteWorkspacePermissionByIDSQL, deleteWorkspacePermissionByIDSQL); err != nil { return fmt.Errorf("prepare query 'DeleteWorkspacePermissionByID': %w", err) @@ -1541,6 +1651,13 @@ type Webhooks struct { Cloud pgtype.Text `json:"cloud"` } +// WorkspacePermissions represents the Postgres composite type "workspace_permissions". +type WorkspacePermissions struct { + WorkspaceID pgtype.Text `json:"workspace_id"` + TeamID pgtype.Text `json:"team_id"` + Role pgtype.Text `json:"role"` +} + // typeResolver looks up the pgtype.ValueTranscoder by Postgres type name. type typeResolver struct { connInfo *pgtype.ConnInfo // types by Postgres type name @@ -1790,6 +1907,17 @@ func (tr *typeResolver) newWebhooks() pgtype.ValueTranscoder { ) } +// newWorkspacePermissions creates a new pgtype.ValueTranscoder for the Postgres +// composite type 'workspace_permissions'. +func (tr *typeResolver) newWorkspacePermissions() pgtype.ValueTranscoder { + return tr.newCompositeValue( + "workspace_permissions", + compositeField{"workspace_id", "text", &pgtype.Text{}}, + compositeField{"team_id", "text", &pgtype.Text{}}, + compositeField{"role", "text", &pgtype.Text{}}, + ) +} + // newConfigurationVersionStatusTimestampsArray creates a new pgtype.ValueTranscoder for the Postgres // '_configuration_version_status_timestamps' array type. func (tr *typeResolver) newConfigurationVersionStatusTimestampsArray() pgtype.ValueTranscoder { @@ -1826,6 +1954,12 @@ func (tr *typeResolver) newTeamsArray() pgtype.ValueTranscoder { return tr.newArrayValue("_teams", "teams", tr.newTeams) } +// newWorkspacePermissionsArray creates a new pgtype.ValueTranscoder for the Postgres +// '_workspace_permissions' array type. +func (tr *typeResolver) newWorkspacePermissionsArray() pgtype.ValueTranscoder { + return tr.newArrayValue("_workspace_permissions", "workspace_permissions", tr.newWorkspacePermissions) +} + const insertAgentTokenSQL = `INSERT INTO agent_tokens ( token_id, created_at, diff --git a/sql/pggen/configuration_version.sql.go b/sql/pggen/configuration_version.sql.go index 919409d2b..906102b8b 100644 --- a/sql/pggen/configuration_version.sql.go +++ b/sql/pggen/configuration_version.sql.go @@ -222,14 +222,14 @@ WHERE configuration_versions.workspace_id = $1 ;` // CountConfigurationVersionsByWorkspaceID implements Querier.CountConfigurationVersionsByWorkspaceID. -func (q *DBQuerier) CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (*int, error) { +func (q *DBQuerier) CountConfigurationVersionsByWorkspaceID(ctx context.Context, workspaceID pgtype.Text) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountConfigurationVersionsByWorkspaceID") row := q.conn.QueryRow(ctx, countConfigurationVersionsByWorkspaceIDSQL, workspaceID) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountConfigurationVersionsByWorkspaceID: %w", err) + return item, fmt.Errorf("query CountConfigurationVersionsByWorkspaceID: %w", err) } - return &item, nil + return item, nil } // CountConfigurationVersionsByWorkspaceIDBatch implements Querier.CountConfigurationVersionsByWorkspaceIDBatch. @@ -238,13 +238,13 @@ func (q *DBQuerier) CountConfigurationVersionsByWorkspaceIDBatch(batch genericBa } // CountConfigurationVersionsByWorkspaceIDScan implements Querier.CountConfigurationVersionsByWorkspaceIDScan. -func (q *DBQuerier) CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountConfigurationVersionsByWorkspaceIDScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountConfigurationVersionsByWorkspaceIDBatch row: %w", err) + return item, fmt.Errorf("scan CountConfigurationVersionsByWorkspaceIDBatch row: %w", err) } - return &item, nil + return item, nil } const findConfigurationVersionByIDSQL = `SELECT diff --git a/sql/pggen/organization.sql.go b/sql/pggen/organization.sql.go index 42756e9e6..4958a14e6 100644 --- a/sql/pggen/organization.sql.go +++ b/sql/pggen/organization.sql.go @@ -229,18 +229,18 @@ func (q *DBQuerier) FindOrganizationsScan(results pgx.BatchResults) ([]FindOrgan const countOrganizationsSQL = `SELECT count(*) FROM organizations -WHERE name = ANY($1) +WHERE name LIKE ANY($1) ;` // CountOrganizations implements Querier.CountOrganizations. -func (q *DBQuerier) CountOrganizations(ctx context.Context, names []string) (*int, error) { +func (q *DBQuerier) CountOrganizations(ctx context.Context, names []string) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountOrganizations") row := q.conn.QueryRow(ctx, countOrganizationsSQL, names) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountOrganizations: %w", err) + return item, fmt.Errorf("query CountOrganizations: %w", err) } - return &item, nil + return item, nil } // CountOrganizationsBatch implements Querier.CountOrganizationsBatch. @@ -249,13 +249,13 @@ func (q *DBQuerier) CountOrganizationsBatch(batch genericBatch, names []string) } // CountOrganizationsScan implements Querier.CountOrganizationsScan. -func (q *DBQuerier) CountOrganizationsScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountOrganizationsScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountOrganizationsBatch row: %w", err) + return item, fmt.Errorf("scan CountOrganizationsBatch row: %w", err) } - return &item, nil + return item, nil } const insertOrganizationSQL = `INSERT INTO organizations ( diff --git a/sql/pggen/repo_connections.sql.go b/sql/pggen/repo_connections.sql.go index 5f50a1394..458da5cb9 100644 --- a/sql/pggen/repo_connections.sql.go +++ b/sql/pggen/repo_connections.sql.go @@ -60,14 +60,14 @@ WHERE webhook_id = $1 ;` // CountRepoConnectionsByID implements Querier.CountRepoConnectionsByID. -func (q *DBQuerier) CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (*int, error) { +func (q *DBQuerier) CountRepoConnectionsByID(ctx context.Context, webhookID pgtype.UUID) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountRepoConnectionsByID") row := q.conn.QueryRow(ctx, countRepoConnectionsByIDSQL, webhookID) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountRepoConnectionsByID: %w", err) + return item, fmt.Errorf("query CountRepoConnectionsByID: %w", err) } - return &item, nil + return item, nil } // CountRepoConnectionsByIDBatch implements Querier.CountRepoConnectionsByIDBatch. @@ -76,13 +76,13 @@ func (q *DBQuerier) CountRepoConnectionsByIDBatch(batch genericBatch, webhookID } // CountRepoConnectionsByIDScan implements Querier.CountRepoConnectionsByIDScan. -func (q *DBQuerier) CountRepoConnectionsByIDScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountRepoConnectionsByIDScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountRepoConnectionsByIDBatch row: %w", err) + return item, fmt.Errorf("scan CountRepoConnectionsByIDBatch row: %w", err) } - return &item, nil + return item, nil } const deleteWorkspaceConnectionByIDSQL = `DELETE diff --git a/sql/pggen/run.sql.go b/sql/pggen/run.sql.go index 420442ed0..977411d3d 100644 --- a/sql/pggen/run.sql.go +++ b/sql/pggen/run.sql.go @@ -333,14 +333,14 @@ type CountRunsParams struct { } // CountRuns implements Querier.CountRuns. -func (q *DBQuerier) CountRuns(ctx context.Context, params CountRunsParams) (*int, error) { +func (q *DBQuerier) CountRuns(ctx context.Context, params CountRunsParams) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountRuns") row := q.conn.QueryRow(ctx, countRunsSQL, params.OrganizationNames, params.WorkspaceIds, params.WorkspaceNames, params.Statuses, params.Speculative) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountRuns: %w", err) + return item, fmt.Errorf("query CountRuns: %w", err) } - return &item, nil + return item, nil } // CountRunsBatch implements Querier.CountRunsBatch. @@ -349,13 +349,13 @@ func (q *DBQuerier) CountRunsBatch(batch genericBatch, params CountRunsParams) { } // CountRunsScan implements Querier.CountRunsScan. -func (q *DBQuerier) CountRunsScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountRunsScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountRunsBatch row: %w", err) + return item, fmt.Errorf("scan CountRunsBatch row: %w", err) } - return &item, nil + return item, nil } const findRunByIDSQL = `SELECT diff --git a/sql/pggen/state_version.sql.go b/sql/pggen/state_version.sql.go index 60a46e331..c77d88320 100644 --- a/sql/pggen/state_version.sql.go +++ b/sql/pggen/state_version.sql.go @@ -151,14 +151,14 @@ AND workspaces.organization_name = $2 ;` // CountStateVersionsByWorkspaceName implements Querier.CountStateVersionsByWorkspaceName. -func (q *DBQuerier) CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (*int, error) { +func (q *DBQuerier) CountStateVersionsByWorkspaceName(ctx context.Context, workspaceName pgtype.Text, organizationName pgtype.Text) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountStateVersionsByWorkspaceName") row := q.conn.QueryRow(ctx, countStateVersionsByWorkspaceNameSQL, workspaceName, organizationName) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountStateVersionsByWorkspaceName: %w", err) + return item, fmt.Errorf("query CountStateVersionsByWorkspaceName: %w", err) } - return &item, nil + return item, nil } // CountStateVersionsByWorkspaceNameBatch implements Querier.CountStateVersionsByWorkspaceNameBatch. @@ -167,13 +167,13 @@ func (q *DBQuerier) CountStateVersionsByWorkspaceNameBatch(batch genericBatch, w } // CountStateVersionsByWorkspaceNameScan implements Querier.CountStateVersionsByWorkspaceNameScan. -func (q *DBQuerier) CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountStateVersionsByWorkspaceNameScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountStateVersionsByWorkspaceNameBatch row: %w", err) + return item, fmt.Errorf("scan CountStateVersionsByWorkspaceNameBatch row: %w", err) } - return &item, nil + return item, nil } const findStateVersionByIDSQL = `SELECT diff --git a/sql/pggen/tags.sql.go b/sql/pggen/tags.sql.go new file mode 100644 index 000000000..4cec60826 --- /dev/null +++ b/sql/pggen/tags.sql.go @@ -0,0 +1,493 @@ +// Code generated by pggen. DO NOT EDIT. + +package pggen + +import ( + "context" + "fmt" + + "github.com/jackc/pgconn" + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" +) + +const insertTagSQL = `INSERT INTO tags ( + tag_id, + name, + organization_name +) VALUES ( + $1, + $2, + $3 +) ON CONFLICT (organization_name, name) DO NOTHING +;` + +type InsertTagParams struct { + TagID pgtype.Text + Name pgtype.Text + OrganizationName pgtype.Text +} + +// InsertTag implements Querier.InsertTag. +func (q *DBQuerier) InsertTag(ctx context.Context, params InsertTagParams) (pgconn.CommandTag, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "InsertTag") + cmdTag, err := q.conn.Exec(ctx, insertTagSQL, params.TagID, params.Name, params.OrganizationName) + if err != nil { + return cmdTag, fmt.Errorf("exec query InsertTag: %w", err) + } + return cmdTag, err +} + +// InsertTagBatch implements Querier.InsertTagBatch. +func (q *DBQuerier) InsertTagBatch(batch genericBatch, params InsertTagParams) { + batch.Queue(insertTagSQL, params.TagID, params.Name, params.OrganizationName) +} + +// InsertTagScan implements Querier.InsertTagScan. +func (q *DBQuerier) InsertTagScan(results pgx.BatchResults) (pgconn.CommandTag, error) { + cmdTag, err := results.Exec() + if err != nil { + return cmdTag, fmt.Errorf("exec InsertTagBatch: %w", err) + } + return cmdTag, err +} + +const insertWorkspaceTagSQL = `INSERT INTO workspace_tags ( + tag_id, + workspace_id +) SELECT $1, $2 + FROM workspaces w + JOIN tags t ON (t.organization_name = w.organization_name) + WHERE w.workspace_id = $2 + AND t.tag_id = $1 +RETURNING tag_id +;` + +// InsertWorkspaceTag implements Querier.InsertWorkspaceTag. +func (q *DBQuerier) InsertWorkspaceTag(ctx context.Context, tagID pgtype.Text, workspaceID pgtype.Text) (pgtype.Text, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "InsertWorkspaceTag") + row := q.conn.QueryRow(ctx, insertWorkspaceTagSQL, tagID, workspaceID) + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("query InsertWorkspaceTag: %w", err) + } + return item, nil +} + +// InsertWorkspaceTagBatch implements Querier.InsertWorkspaceTagBatch. +func (q *DBQuerier) InsertWorkspaceTagBatch(batch genericBatch, tagID pgtype.Text, workspaceID pgtype.Text) { + batch.Queue(insertWorkspaceTagSQL, tagID, workspaceID) +} + +// InsertWorkspaceTagScan implements Querier.InsertWorkspaceTagScan. +func (q *DBQuerier) InsertWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error) { + row := results.QueryRow() + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("scan InsertWorkspaceTagBatch row: %w", err) + } + return item, nil +} + +const insertWorkspaceTagByNameSQL = `INSERT INTO workspace_tags ( + tag_id, + workspace_id +) SELECT t.tag_id, $1 + FROM workspaces w + JOIN tags t ON (t.organization_name = w.organization_name) + WHERE t.name = $2 +RETURNING tag_id +;` + +// InsertWorkspaceTagByName implements Querier.InsertWorkspaceTagByName. +func (q *DBQuerier) InsertWorkspaceTagByName(ctx context.Context, workspaceID pgtype.Text, tagName pgtype.Text) (pgtype.Text, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "InsertWorkspaceTagByName") + row := q.conn.QueryRow(ctx, insertWorkspaceTagByNameSQL, workspaceID, tagName) + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("query InsertWorkspaceTagByName: %w", err) + } + return item, nil +} + +// InsertWorkspaceTagByNameBatch implements Querier.InsertWorkspaceTagByNameBatch. +func (q *DBQuerier) InsertWorkspaceTagByNameBatch(batch genericBatch, workspaceID pgtype.Text, tagName pgtype.Text) { + batch.Queue(insertWorkspaceTagByNameSQL, workspaceID, tagName) +} + +// InsertWorkspaceTagByNameScan implements Querier.InsertWorkspaceTagByNameScan. +func (q *DBQuerier) InsertWorkspaceTagByNameScan(results pgx.BatchResults) (pgtype.Text, error) { + row := results.QueryRow() + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("scan InsertWorkspaceTagByNameBatch row: %w", err) + } + return item, nil +} + +const findTagsSQL = `SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM tags t +WHERE t.organization_name = $1 +LIMIT $2 +OFFSET $3 +;` + +type FindTagsParams struct { + OrganizationName pgtype.Text + Limit int + Offset int +} + +type FindTagsRow struct { + TagID pgtype.Text `json:"tag_id"` + Name pgtype.Text `json:"name"` + OrganizationName pgtype.Text `json:"organization_name"` + InstanceCount int `json:"instance_count"` +} + +// FindTags implements Querier.FindTags. +func (q *DBQuerier) FindTags(ctx context.Context, params FindTagsParams) ([]FindTagsRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindTags") + rows, err := q.conn.Query(ctx, findTagsSQL, params.OrganizationName, params.Limit, params.Offset) + if err != nil { + return nil, fmt.Errorf("query FindTags: %w", err) + } + defer rows.Close() + items := []FindTagsRow{} + for rows.Next() { + var item FindTagsRow + if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return nil, fmt.Errorf("scan FindTags row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindTags rows: %w", err) + } + return items, err +} + +// FindTagsBatch implements Querier.FindTagsBatch. +func (q *DBQuerier) FindTagsBatch(batch genericBatch, params FindTagsParams) { + batch.Queue(findTagsSQL, params.OrganizationName, params.Limit, params.Offset) +} + +// FindTagsScan implements Querier.FindTagsScan. +func (q *DBQuerier) FindTagsScan(results pgx.BatchResults) ([]FindTagsRow, error) { + rows, err := results.Query() + if err != nil { + return nil, fmt.Errorf("query FindTagsBatch: %w", err) + } + defer rows.Close() + items := []FindTagsRow{} + for rows.Next() { + var item FindTagsRow + if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return nil, fmt.Errorf("scan FindTagsBatch row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindTagsBatch rows: %w", err) + } + return items, err +} + +const findWorkspaceTagsSQL = `SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM workspace_tags wt +JOIN tags t USING (tag_id) +WHERE wt.workspace_id = $1 +LIMIT $2 +OFFSET $3 +;` + +type FindWorkspaceTagsParams struct { + WorkspaceID pgtype.Text + Limit int + Offset int +} + +type FindWorkspaceTagsRow struct { + TagID pgtype.Text `json:"tag_id"` + Name pgtype.Text `json:"name"` + OrganizationName pgtype.Text `json:"organization_name"` + InstanceCount int `json:"instance_count"` +} + +// FindWorkspaceTags implements Querier.FindWorkspaceTags. +func (q *DBQuerier) FindWorkspaceTags(ctx context.Context, params FindWorkspaceTagsParams) ([]FindWorkspaceTagsRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspaceTags") + rows, err := q.conn.Query(ctx, findWorkspaceTagsSQL, params.WorkspaceID, params.Limit, params.Offset) + if err != nil { + return nil, fmt.Errorf("query FindWorkspaceTags: %w", err) + } + defer rows.Close() + items := []FindWorkspaceTagsRow{} + for rows.Next() { + var item FindWorkspaceTagsRow + if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return nil, fmt.Errorf("scan FindWorkspaceTags row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindWorkspaceTags rows: %w", err) + } + return items, err +} + +// FindWorkspaceTagsBatch implements Querier.FindWorkspaceTagsBatch. +func (q *DBQuerier) FindWorkspaceTagsBatch(batch genericBatch, params FindWorkspaceTagsParams) { + batch.Queue(findWorkspaceTagsSQL, params.WorkspaceID, params.Limit, params.Offset) +} + +// FindWorkspaceTagsScan implements Querier.FindWorkspaceTagsScan. +func (q *DBQuerier) FindWorkspaceTagsScan(results pgx.BatchResults) ([]FindWorkspaceTagsRow, error) { + rows, err := results.Query() + if err != nil { + return nil, fmt.Errorf("query FindWorkspaceTagsBatch: %w", err) + } + defer rows.Close() + items := []FindWorkspaceTagsRow{} + for rows.Next() { + var item FindWorkspaceTagsRow + if err := rows.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return nil, fmt.Errorf("scan FindWorkspaceTagsBatch row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindWorkspaceTagsBatch rows: %w", err) + } + return items, err +} + +const findTagByNameSQL = `SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM tags t +WHERE t.name = $1 +AND t.organization_name = $2 +;` + +type FindTagByNameRow struct { + TagID pgtype.Text `json:"tag_id"` + Name pgtype.Text `json:"name"` + OrganizationName pgtype.Text `json:"organization_name"` + InstanceCount int `json:"instance_count"` +} + +// FindTagByName implements Querier.FindTagByName. +func (q *DBQuerier) FindTagByName(ctx context.Context, name pgtype.Text, organizationName pgtype.Text) (FindTagByNameRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindTagByName") + row := q.conn.QueryRow(ctx, findTagByNameSQL, name, organizationName) + var item FindTagByNameRow + if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return item, fmt.Errorf("query FindTagByName: %w", err) + } + return item, nil +} + +// FindTagByNameBatch implements Querier.FindTagByNameBatch. +func (q *DBQuerier) FindTagByNameBatch(batch genericBatch, name pgtype.Text, organizationName pgtype.Text) { + batch.Queue(findTagByNameSQL, name, organizationName) +} + +// FindTagByNameScan implements Querier.FindTagByNameScan. +func (q *DBQuerier) FindTagByNameScan(results pgx.BatchResults) (FindTagByNameRow, error) { + row := results.QueryRow() + var item FindTagByNameRow + if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return item, fmt.Errorf("scan FindTagByNameBatch row: %w", err) + } + return item, nil +} + +const findTagByIDSQL = `SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM tags t +WHERE t.tag_id = $1 +AND t.organization_name = $2 +;` + +type FindTagByIDRow struct { + TagID pgtype.Text `json:"tag_id"` + Name pgtype.Text `json:"name"` + OrganizationName pgtype.Text `json:"organization_name"` + InstanceCount int `json:"instance_count"` +} + +// FindTagByID implements Querier.FindTagByID. +func (q *DBQuerier) FindTagByID(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (FindTagByIDRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindTagByID") + row := q.conn.QueryRow(ctx, findTagByIDSQL, tagID, organizationName) + var item FindTagByIDRow + if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return item, fmt.Errorf("query FindTagByID: %w", err) + } + return item, nil +} + +// FindTagByIDBatch implements Querier.FindTagByIDBatch. +func (q *DBQuerier) FindTagByIDBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text) { + batch.Queue(findTagByIDSQL, tagID, organizationName) +} + +// FindTagByIDScan implements Querier.FindTagByIDScan. +func (q *DBQuerier) FindTagByIDScan(results pgx.BatchResults) (FindTagByIDRow, error) { + row := results.QueryRow() + var item FindTagByIDRow + if err := row.Scan(&item.TagID, &item.Name, &item.OrganizationName, &item.InstanceCount); err != nil { + return item, fmt.Errorf("scan FindTagByIDBatch row: %w", err) + } + return item, nil +} + +const countTagsSQL = `SELECT count(*) +FROM tags t +WHERE t.organization_name = $1 +;` + +// CountTags implements Querier.CountTags. +func (q *DBQuerier) CountTags(ctx context.Context, organizationName pgtype.Text) (int, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "CountTags") + row := q.conn.QueryRow(ctx, countTagsSQL, organizationName) + var item int + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("query CountTags: %w", err) + } + return item, nil +} + +// CountTagsBatch implements Querier.CountTagsBatch. +func (q *DBQuerier) CountTagsBatch(batch genericBatch, organizationName pgtype.Text) { + batch.Queue(countTagsSQL, organizationName) +} + +// CountTagsScan implements Querier.CountTagsScan. +func (q *DBQuerier) CountTagsScan(results pgx.BatchResults) (int, error) { + row := results.QueryRow() + var item int + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("scan CountTagsBatch row: %w", err) + } + return item, nil +} + +const countWorkspaceTagsSQL = `SELECT count(*) +FROM workspace_tags wt +WHERE wt.workspace_id = $1 +;` + +// CountWorkspaceTags implements Querier.CountWorkspaceTags. +func (q *DBQuerier) CountWorkspaceTags(ctx context.Context, workspaceID pgtype.Text) (int, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "CountWorkspaceTags") + row := q.conn.QueryRow(ctx, countWorkspaceTagsSQL, workspaceID) + var item int + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("query CountWorkspaceTags: %w", err) + } + return item, nil +} + +// CountWorkspaceTagsBatch implements Querier.CountWorkspaceTagsBatch. +func (q *DBQuerier) CountWorkspaceTagsBatch(batch genericBatch, workspaceID pgtype.Text) { + batch.Queue(countWorkspaceTagsSQL, workspaceID) +} + +// CountWorkspaceTagsScan implements Querier.CountWorkspaceTagsScan. +func (q *DBQuerier) CountWorkspaceTagsScan(results pgx.BatchResults) (int, error) { + row := results.QueryRow() + var item int + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("scan CountWorkspaceTagsBatch row: %w", err) + } + return item, nil +} + +const deleteTagSQL = `DELETE +FROM tags +WHERE tag_id = $1 +AND organization_name = $2 +RETURNING tag_id +;` + +// DeleteTag implements Querier.DeleteTag. +func (q *DBQuerier) DeleteTag(ctx context.Context, tagID pgtype.Text, organizationName pgtype.Text) (pgtype.Text, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "DeleteTag") + row := q.conn.QueryRow(ctx, deleteTagSQL, tagID, organizationName) + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("query DeleteTag: %w", err) + } + return item, nil +} + +// DeleteTagBatch implements Querier.DeleteTagBatch. +func (q *DBQuerier) DeleteTagBatch(batch genericBatch, tagID pgtype.Text, organizationName pgtype.Text) { + batch.Queue(deleteTagSQL, tagID, organizationName) +} + +// DeleteTagScan implements Querier.DeleteTagScan. +func (q *DBQuerier) DeleteTagScan(results pgx.BatchResults) (pgtype.Text, error) { + row := results.QueryRow() + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("scan DeleteTagBatch row: %w", err) + } + return item, nil +} + +const deleteWorkspaceTagSQL = `DELETE +FROM workspace_tags +WHERE workspace_id = $1 +AND tag_id = $2 +RETURNING tag_id +;` + +// DeleteWorkspaceTag implements Querier.DeleteWorkspaceTag. +func (q *DBQuerier) DeleteWorkspaceTag(ctx context.Context, workspaceID pgtype.Text, tagID pgtype.Text) (pgtype.Text, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "DeleteWorkspaceTag") + row := q.conn.QueryRow(ctx, deleteWorkspaceTagSQL, workspaceID, tagID) + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("query DeleteWorkspaceTag: %w", err) + } + return item, nil +} + +// DeleteWorkspaceTagBatch implements Querier.DeleteWorkspaceTagBatch. +func (q *DBQuerier) DeleteWorkspaceTagBatch(batch genericBatch, workspaceID pgtype.Text, tagID pgtype.Text) { + batch.Queue(deleteWorkspaceTagSQL, workspaceID, tagID) +} + +// DeleteWorkspaceTagScan implements Querier.DeleteWorkspaceTagScan. +func (q *DBQuerier) DeleteWorkspaceTagScan(results pgx.BatchResults) (pgtype.Text, error) { + row := results.QueryRow() + var item pgtype.Text + if err := row.Scan(&item); err != nil { + return item, fmt.Errorf("scan DeleteWorkspaceTagBatch row: %w", err) + } + return item, nil +} diff --git a/sql/pggen/workspace.sql.go b/sql/pggen/workspace.sql.go index 8e5992f6b..78f9d967f 100644 --- a/sql/pggen/workspace.sql.go +++ b/sql/pggen/workspace.sql.go @@ -113,26 +113,50 @@ func (q *DBQuerier) InsertWorkspaceScan(results pgx.BatchResults) (pgconn.Comman const findWorkspacesSQL = `SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, - (ul.*)::"users" AS user_lock, - (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + ( + SELECT (u.*)::"users" + FROM users u + WHERE u.username = w.lock_username + ) AS user_lock, + ( + SELECT (rl.*)::"runs" + FROM runs rl + WHERE rl.run_id = w.lock_run_id + ) AS run_lock, + ( + SELECT (rc.*)::"repo_connections" + FROM repo_connections rc + WHERE rc.workspace_id = w.workspace_id + ) AS workspace_connection, + ( + SELECT (wh.*)::"webhooks" + FROM webhooks wh + JOIN repo_connections rc USING (webhook_id) + WHERE rc.workspace_id = w.workspace_id + ) AS webhook FROM workspaces w -LEFT JOIN users ul ON w.lock_username = ul.username -LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id WHERE w.name LIKE $1 || '%' AND w.organization_name LIKE ANY($2) +GROUP BY w.workspace_id, r.status +HAVING array_agg(t.name) @> $3 ORDER BY w.updated_at DESC -LIMIT $3 -OFFSET $4 +LIMIT $4 +OFFSET $5 ;` type FindWorkspacesParams struct { Prefix pgtype.Text OrganizationNames []string + Tags []string Limit int Offset int } @@ -165,6 +189,7 @@ type FindWorkspacesRow struct { Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` @@ -175,7 +200,7 @@ type FindWorkspacesRow struct { // FindWorkspaces implements Querier.FindWorkspaces. func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesParams) ([]FindWorkspacesRow, error) { ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspaces") - rows, err := q.conn.Query(ctx, findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Limit, params.Offset) + rows, err := q.conn.Query(ctx, findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags, params.Limit, params.Offset) if err != nil { return nil, fmt.Errorf("query FindWorkspaces: %w", err) } @@ -187,7 +212,7 @@ func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesPar webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return nil, fmt.Errorf("scan FindWorkspaces row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -212,7 +237,7 @@ func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesPar // FindWorkspacesBatch implements Querier.FindWorkspacesBatch. func (q *DBQuerier) FindWorkspacesBatch(batch genericBatch, params FindWorkspacesParams) { - batch.Queue(findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Limit, params.Offset) + batch.Queue(findWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags, params.Limit, params.Offset) } // FindWorkspacesScan implements Querier.FindWorkspacesScan. @@ -229,7 +254,7 @@ func (q *DBQuerier) FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspac webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -253,39 +278,55 @@ func (q *DBQuerier) FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspac } const countWorkspacesSQL = `SELECT count(*) -FROM workspaces -WHERE name LIKE $1 || '%' -AND organization_name LIKE ANY($2) +FROM workspaces w +LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON w.workspace_id = wt.workspace_id +WHERE w.name LIKE $1 || '%' +AND w.organization_name LIKE ANY($2) +AND CASE WHEN cardinality($3::text[]) > 0 THEN t.name LIKE ANY($3) + ELSE 1 = 1 + END ;` +type CountWorkspacesParams struct { + Prefix pgtype.Text + OrganizationNames []string + Tags []string +} + // CountWorkspaces implements Querier.CountWorkspaces. -func (q *DBQuerier) CountWorkspaces(ctx context.Context, prefix pgtype.Text, organizationNames []string) (*int, error) { +func (q *DBQuerier) CountWorkspaces(ctx context.Context, params CountWorkspacesParams) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountWorkspaces") - row := q.conn.QueryRow(ctx, countWorkspacesSQL, prefix, organizationNames) + row := q.conn.QueryRow(ctx, countWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountWorkspaces: %w", err) + return item, fmt.Errorf("query CountWorkspaces: %w", err) } - return &item, nil + return item, nil } // CountWorkspacesBatch implements Querier.CountWorkspacesBatch. -func (q *DBQuerier) CountWorkspacesBatch(batch genericBatch, prefix pgtype.Text, organizationNames []string) { - batch.Queue(countWorkspacesSQL, prefix, organizationNames) +func (q *DBQuerier) CountWorkspacesBatch(batch genericBatch, params CountWorkspacesParams) { + batch.Queue(countWorkspacesSQL, params.Prefix, params.OrganizationNames, params.Tags) } // CountWorkspacesScan implements Querier.CountWorkspacesScan. -func (q *DBQuerier) CountWorkspacesScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountWorkspacesScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountWorkspacesBatch row: %w", err) + return item, fmt.Errorf("scan CountWorkspacesBatch row: %w", err) } - return &item, nil + return item, nil } const findWorkspacesByWebhookIDSQL = `SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -327,6 +368,7 @@ type FindWorkspacesByWebhookIDRow struct { Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` @@ -349,7 +391,7 @@ func (q *DBQuerier) FindWorkspacesByWebhookID(ctx context.Context, webhookID pgt webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesByWebhookIDRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesByWebhookID row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -391,7 +433,7 @@ func (q *DBQuerier) FindWorkspacesByWebhookIDScan(results pgx.BatchResults) ([]F webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesByWebhookIDRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesByWebhookIDBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -416,6 +458,12 @@ func (q *DBQuerier) FindWorkspacesByWebhookIDScan(results pgx.BatchResults) ([]F const findWorkspacesByUsernameSQL = `SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -472,6 +520,7 @@ type FindWorkspacesByUsernameRow struct { Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` @@ -494,7 +543,7 @@ func (q *DBQuerier) FindWorkspacesByUsername(ctx context.Context, params FindWor webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesByUsernameRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesByUsername row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -536,7 +585,7 @@ func (q *DBQuerier) FindWorkspacesByUsernameScan(results pgx.BatchResults) ([]Fi webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesByUsernameRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesByUsernameBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -570,14 +619,14 @@ AND u.username = $2 ;` // CountWorkspacesByUsername implements Querier.CountWorkspacesByUsername. -func (q *DBQuerier) CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (*int, error) { +func (q *DBQuerier) CountWorkspacesByUsername(ctx context.Context, organizationName pgtype.Text, username pgtype.Text) (int, error) { ctx = context.WithValue(ctx, "pggen_query_name", "CountWorkspacesByUsername") row := q.conn.QueryRow(ctx, countWorkspacesByUsernameSQL, organizationName, username) var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("query CountWorkspacesByUsername: %w", err) + return item, fmt.Errorf("query CountWorkspacesByUsername: %w", err) } - return &item, nil + return item, nil } // CountWorkspacesByUsernameBatch implements Querier.CountWorkspacesByUsernameBatch. @@ -586,16 +635,22 @@ func (q *DBQuerier) CountWorkspacesByUsernameBatch(batch genericBatch, organizat } // CountWorkspacesByUsernameScan implements Querier.CountWorkspacesByUsernameScan. -func (q *DBQuerier) CountWorkspacesByUsernameScan(results pgx.BatchResults) (*int, error) { +func (q *DBQuerier) CountWorkspacesByUsernameScan(results pgx.BatchResults) (int, error) { row := results.QueryRow() var item int if err := row.Scan(&item); err != nil { - return &item, fmt.Errorf("scan CountWorkspacesByUsernameBatch row: %w", err) + return item, fmt.Errorf("scan CountWorkspacesByUsernameBatch row: %w", err) } - return &item, nil + return item, nil } const findWorkspaceByNameSQL = `SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -638,6 +693,7 @@ type FindWorkspaceByNameRow struct { Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` @@ -654,7 +710,7 @@ func (q *DBQuerier) FindWorkspaceByName(ctx context.Context, name pgtype.Text, o runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return item, fmt.Errorf("query FindWorkspaceByName: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -685,7 +741,7 @@ func (q *DBQuerier) FindWorkspaceByNameScan(results pgx.BatchResults) (FindWorks runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return item, fmt.Errorf("scan FindWorkspaceByNameBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -704,6 +760,12 @@ func (q *DBQuerier) FindWorkspaceByNameScan(results pgx.BatchResults) (FindWorks } const findWorkspaceByIDSQL = `SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -745,6 +807,7 @@ type FindWorkspaceByIDRow struct { Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` @@ -761,7 +824,7 @@ func (q *DBQuerier) FindWorkspaceByID(ctx context.Context, id pgtype.Text) (Find runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return item, fmt.Errorf("query FindWorkspaceByID: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -792,7 +855,7 @@ func (q *DBQuerier) FindWorkspaceByIDScan(results pgx.BatchResults) (FindWorkspa runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return item, fmt.Errorf("scan FindWorkspaceByIDBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -811,6 +874,12 @@ func (q *DBQuerier) FindWorkspaceByIDScan(results pgx.BatchResults) (FindWorkspa } const findWorkspaceByIDForUpdateSQL = `SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -852,6 +921,7 @@ type FindWorkspaceByIDForUpdateRow struct { Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` @@ -868,7 +938,7 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdate(ctx context.Context, id pgtype.Te runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return item, fmt.Errorf("query FindWorkspaceByIDForUpdate: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -899,7 +969,7 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdateScan(results pgx.BatchResults) (Fi runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.FileTriggersEnabled, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { return item, fmt.Errorf("scan FindWorkspaceByIDForUpdateBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { diff --git a/sql/pggen/workspace_permission.sql.go b/sql/pggen/workspace_permission.sql.go index 446436938..c15eed64f 100644 --- a/sql/pggen/workspace_permission.sql.go +++ b/sql/pggen/workspace_permission.sql.go @@ -54,66 +54,71 @@ func (q *DBQuerier) UpsertWorkspacePermissionScan(results pgx.BatchResults) (pgc return cmdTag, err } -const findWorkspacePermissionsSQL = `SELECT - wp.role, - t.name AS team_name -FROM workspace_permissions wp -JOIN teams t USING (team_id) -JOIN workspaces w USING (workspace_id) -WHERE wp.workspace_id = $1 +const findWorkspacePolicyByIDSQL = `SELECT + w.organization_name, + w.workspace_id, + ( + SELECT array_remove(array_agg(t.*), NULL) + FROM teams t + JOIN workspace_permissions wp USING (team_id) + WHERE wp.workspace_id = w.workspace_id + ) AS teams, + ( + SELECT array_remove(array_agg(wp.*), NULL) + FROM workspace_permissions wp + WHERE wp.workspace_id = w.workspace_id + ) AS workspace_permissions +FROM workspaces w +WHERE workspace_id = $1 ;` -type FindWorkspacePermissionsRow struct { - Role pgtype.Text `json:"role"` - TeamName pgtype.Text `json:"team_name"` +type FindWorkspacePolicyByIDRow struct { + OrganizationName pgtype.Text `json:"organization_name"` + WorkspaceID pgtype.Text `json:"workspace_id"` + Teams []Teams `json:"teams"` + WorkspacePermissions []WorkspacePermissions `json:"workspace_permissions"` } -// FindWorkspacePermissions implements Querier.FindWorkspacePermissions. -func (q *DBQuerier) FindWorkspacePermissions(ctx context.Context, workspaceID pgtype.Text) ([]FindWorkspacePermissionsRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspacePermissions") - rows, err := q.conn.Query(ctx, findWorkspacePermissionsSQL, workspaceID) - if err != nil { - return nil, fmt.Errorf("query FindWorkspacePermissions: %w", err) +// FindWorkspacePolicyByID implements Querier.FindWorkspacePolicyByID. +func (q *DBQuerier) FindWorkspacePolicyByID(ctx context.Context, workspaceID pgtype.Text) (FindWorkspacePolicyByIDRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspacePolicyByID") + row := q.conn.QueryRow(ctx, findWorkspacePolicyByIDSQL, workspaceID) + var item FindWorkspacePolicyByIDRow + teamsArray := q.types.newTeamsArray() + workspacePermissionsArray := q.types.newWorkspacePermissionsArray() + if err := row.Scan(&item.OrganizationName, &item.WorkspaceID, teamsArray, workspacePermissionsArray); err != nil { + return item, fmt.Errorf("query FindWorkspacePolicyByID: %w", err) } - defer rows.Close() - items := []FindWorkspacePermissionsRow{} - for rows.Next() { - var item FindWorkspacePermissionsRow - if err := rows.Scan(&item.Role, &item.TeamName); err != nil { - return nil, fmt.Errorf("scan FindWorkspacePermissions row: %w", err) - } - items = append(items, item) + if err := teamsArray.AssignTo(&item.Teams); err != nil { + return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWorkspacePermissions rows: %w", err) + if err := workspacePermissionsArray.AssignTo(&item.WorkspacePermissions); err != nil { + return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err) } - return items, err + return item, nil } -// FindWorkspacePermissionsBatch implements Querier.FindWorkspacePermissionsBatch. -func (q *DBQuerier) FindWorkspacePermissionsBatch(batch genericBatch, workspaceID pgtype.Text) { - batch.Queue(findWorkspacePermissionsSQL, workspaceID) +// FindWorkspacePolicyByIDBatch implements Querier.FindWorkspacePolicyByIDBatch. +func (q *DBQuerier) FindWorkspacePolicyByIDBatch(batch genericBatch, workspaceID pgtype.Text) { + batch.Queue(findWorkspacePolicyByIDSQL, workspaceID) } -// FindWorkspacePermissionsScan implements Querier.FindWorkspacePermissionsScan. -func (q *DBQuerier) FindWorkspacePermissionsScan(results pgx.BatchResults) ([]FindWorkspacePermissionsRow, error) { - rows, err := results.Query() - if err != nil { - return nil, fmt.Errorf("query FindWorkspacePermissionsBatch: %w", err) +// FindWorkspacePolicyByIDScan implements Querier.FindWorkspacePolicyByIDScan. +func (q *DBQuerier) FindWorkspacePolicyByIDScan(results pgx.BatchResults) (FindWorkspacePolicyByIDRow, error) { + row := results.QueryRow() + var item FindWorkspacePolicyByIDRow + teamsArray := q.types.newTeamsArray() + workspacePermissionsArray := q.types.newWorkspacePermissionsArray() + if err := row.Scan(&item.OrganizationName, &item.WorkspaceID, teamsArray, workspacePermissionsArray); err != nil { + return item, fmt.Errorf("scan FindWorkspacePolicyByIDBatch row: %w", err) } - defer rows.Close() - items := []FindWorkspacePermissionsRow{} - for rows.Next() { - var item FindWorkspacePermissionsRow - if err := rows.Scan(&item.Role, &item.TeamName); err != nil { - return nil, fmt.Errorf("scan FindWorkspacePermissionsBatch row: %w", err) - } - items = append(items, item) + if err := teamsArray.AssignTo(&item.Teams); err != nil { + return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWorkspacePermissionsBatch rows: %w", err) + if err := workspacePermissionsArray.AssignTo(&item.WorkspacePermissions); err != nil { + return item, fmt.Errorf("assign FindWorkspacePolicyByID row: %w", err) } - return items, err + return item, nil } const deleteWorkspacePermissionByIDSQL = `DELETE diff --git a/sql/queries/organization.sql b/sql/queries/organization.sql index 582a83d78..b90be9da0 100644 --- a/sql/queries/organization.sql +++ b/sql/queries/organization.sql @@ -28,7 +28,7 @@ LIMIT pggen.arg('limit') OFFSET pggen.arg('offset') -- name: CountOrganizations :one SELECT count(*) FROM organizations -WHERE name = ANY(pggen.arg('names')) +WHERE name LIKE ANY(pggen.arg('names')) ; -- name: InsertOrganization :exec diff --git a/sql/queries/tags.sql b/sql/queries/tags.sql new file mode 100644 index 000000000..2f5134ba7 --- /dev/null +++ b/sql/queries/tags.sql @@ -0,0 +1,117 @@ +-- name: InsertTag :exec +INSERT INTO tags ( + tag_id, + name, + organization_name +) VALUES ( + pggen.arg('tag_id'), + pggen.arg('name'), + pggen.arg('organization_name') +) ON CONFLICT (organization_name, name) DO NOTHING +; + +-- name: InsertWorkspaceTag :one +INSERT INTO workspace_tags ( + tag_id, + workspace_id +) SELECT pggen.arg('tag_id'), pggen.arg('workspace_id') + FROM workspaces w + JOIN tags t ON (t.organization_name = w.organization_name) + WHERE w.workspace_id = pggen.arg('workspace_id') + AND t.tag_id = pggen.arg('tag_id') +RETURNING tag_id +; + +-- name: InsertWorkspaceTagByName :one +INSERT INTO workspace_tags ( + tag_id, + workspace_id +) SELECT t.tag_id, pggen.arg('workspace_id') + FROM workspaces w + JOIN tags t ON (t.organization_name = w.organization_name) + WHERE t.name = pggen.arg('tag_name') +RETURNING tag_id +; + +-- name: FindTags :many +SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM tags t +WHERE t.organization_name = pggen.arg('organization_name') +LIMIT pggen.arg('limit') +OFFSET pggen.arg('offset') +; + +-- name: FindWorkspaceTags :many +SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM workspace_tags wt +JOIN tags t USING (tag_id) +WHERE wt.workspace_id = pggen.arg('workspace_id') +LIMIT pggen.arg('limit') +OFFSET pggen.arg('offset') +; + +-- name: FindTagByName :one +SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM tags t +WHERE t.name = pggen.arg('name') +AND t.organization_name = pggen.arg('organization_name') +; + +-- name: FindTagByID :one +SELECT + t.*, + ( + SELECT count(*) + FROM workspace_tags wt + WHERE wt.tag_id = t.tag_id + ) AS instance_count +FROM tags t +WHERE t.tag_id = pggen.arg('tag_id') +AND t.organization_name = pggen.arg('organization_name') +; + +-- name: CountTags :one +SELECT count(*) +FROM tags t +WHERE t.organization_name = pggen.arg('organization_name') +; + +-- name: CountWorkspaceTags :one +SELECT count(*) +FROM workspace_tags wt +WHERE wt.workspace_id = pggen.arg('workspace_id') +; + +-- name: DeleteTag :one +DELETE +FROM tags +WHERE tag_id = pggen.arg('tag_id') +AND organization_name = pggen.arg('organization_name') +RETURNING tag_id +; + +-- name: DeleteWorkspaceTag :one +DELETE +FROM workspace_tags +WHERE workspace_id = pggen.arg('workspace_id') +AND tag_id = pggen.arg('tag_id') +RETURNING tag_id +; diff --git a/sql/queries/workspace.sql b/sql/queries/workspace.sql index 575356190..a5f7a8504 100644 --- a/sql/queries/workspace.sql +++ b/sql/queries/workspace.sql @@ -52,18 +52,41 @@ INSERT INTO workspaces ( -- name: FindWorkspaces :many SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, - (ul.*)::"users" AS user_lock, - (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + ( + SELECT (u.*)::"users" + FROM users u + WHERE u.username = w.lock_username + ) AS user_lock, + ( + SELECT (rl.*)::"runs" + FROM runs rl + WHERE rl.run_id = w.lock_run_id + ) AS run_lock, + ( + SELECT (rc.*)::"repo_connections" + FROM repo_connections rc + WHERE rc.workspace_id = w.workspace_id + ) AS workspace_connection, + ( + SELECT (wh.*)::"webhooks" + FROM webhooks wh + JOIN repo_connections rc USING (webhook_id) + WHERE rc.workspace_id = w.workspace_id + ) AS webhook FROM workspaces w -LEFT JOIN users ul ON w.lock_username = ul.username -LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id WHERE w.name LIKE pggen.arg('prefix') || '%' AND w.organization_name LIKE ANY(pggen.arg('organization_names')) +GROUP BY w.workspace_id, r.status +HAVING array_agg(t.name) @> pggen.arg('tags') ORDER BY w.updated_at DESC LIMIT pggen.arg('limit') OFFSET pggen.arg('offset') @@ -71,14 +94,24 @@ OFFSET pggen.arg('offset') -- name: CountWorkspaces :one SELECT count(*) -FROM workspaces -WHERE name LIKE pggen.arg('prefix') || '%' -AND organization_name LIKE ANY(pggen.arg('organization_names')) +FROM workspaces w +LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON w.workspace_id = wt.workspace_id +WHERE w.name LIKE pggen.arg('prefix') || '%' +AND w.organization_name LIKE ANY(pggen.arg('organization_names')) +AND CASE WHEN cardinality(pggen.arg('tags')::text[]) > 0 THEN t.name LIKE ANY(pggen.arg('tags')) + ELSE 1 = 1 + END ; -- name: FindWorkspacesByWebhookID :many SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -95,6 +128,12 @@ WHERE h.webhook_id = pggen.arg('webhook_id') -- name: FindWorkspacesByUsername :many SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -129,6 +168,12 @@ AND u.username = pggen.arg('username') -- name: FindWorkspaceByName :one SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -145,6 +190,12 @@ AND w.organization_name = pggen.arg('organization_name') -- name: FindWorkspaceByID :one SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, @@ -160,6 +211,12 @@ WHERE w.workspace_id = pggen.arg('id') -- name: FindWorkspaceByIDForUpdate :one SELECT w.*, + ( + SELECT array_agg(name) + FROM tags + JOIN workspace_tags wt USING (tag_id) + WHERE wt.workspace_id = w.workspace_id + ) AS tags, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, diff --git a/sql/queries/workspace_permission.sql b/sql/queries/workspace_permission.sql index a05dabd0e..65f1bb37d 100644 --- a/sql/queries/workspace_permission.sql +++ b/sql/queries/workspace_permission.sql @@ -12,14 +12,23 @@ INSERT INTO workspace_permissions ( ON CONFLICT (workspace_id, team_id) DO UPDATE SET role = pggen.arg('role') ; --- name: FindWorkspacePermissions :many +-- name: FindWorkspacePolicyByID :one SELECT - wp.role, - t.name AS team_name -FROM workspace_permissions wp -JOIN teams t USING (team_id) -JOIN workspaces w USING (workspace_id) -WHERE wp.workspace_id = pggen.arg('workspace_id') + w.organization_name, + w.workspace_id, + ( + SELECT array_remove(array_agg(t.*), NULL) + FROM teams t + JOIN workspace_permissions wp USING (team_id) + WHERE wp.workspace_id = w.workspace_id + ) AS teams, + ( + SELECT array_remove(array_agg(wp.*), NULL) + FROM workspace_permissions wp + WHERE wp.workspace_id = w.workspace_id + ) AS workspace_permissions +FROM workspaces w +WHERE workspace_id = pggen.arg('workspace_id') ; -- name: DeleteWorkspacePermissionByID :exec diff --git a/state/db.go b/state/db.go index b7002f67f..8efcb806c 100644 --- a/state/db.go +++ b/state/db.go @@ -102,7 +102,7 @@ func (db *pgdb) listVersions(ctx context.Context, opts StateVersionListOptions) return &VersionList{ Items: items, - Pagination: otf.NewPagination(opts.ListOptions, *count), + Pagination: otf.NewPagination(opts.ListOptions, count), }, nil } diff --git a/variable/service.go b/variable/service.go index 3d2506ccf..1726604b3 100644 --- a/variable/service.go +++ b/variable/service.go @@ -7,7 +7,6 @@ import ( "github.com/gorilla/mux" "github.com/leg100/otf" "github.com/leg100/otf/http/html" - "github.com/leg100/otf/policy" "github.com/leg100/otf/rbac" "github.com/leg100/otf/workspace" "github.com/pkg/errors" @@ -41,7 +40,6 @@ type ( otf.DB html.Renderer logr.Logger - policy.PolicyService } ) @@ -53,10 +51,9 @@ func NewService(opts Options) *service { } svc.web = &web{ - Renderer: opts.Renderer, - Service: opts.WorkspaceService, - PolicyService: opts.PolicyService, - svc: &svc, + Renderer: opts.Renderer, + Service: opts.WorkspaceService, + svc: &svc, } return &svc diff --git a/variable/web.go b/variable/web.go index 162c8e272..42d13c533 100644 --- a/variable/web.go +++ b/variable/web.go @@ -8,7 +8,6 @@ import ( "github.com/leg100/otf/http/decode" "github.com/leg100/otf/http/html" "github.com/leg100/otf/http/html/paths" - "github.com/leg100/otf/policy" "github.com/leg100/otf/rbac" "github.com/leg100/otf/workspace" ) @@ -16,7 +15,6 @@ import ( type web struct { html.Renderer workspace.Service - policy.PolicyService svc Service } diff --git a/workspace/authorizer.go b/workspace/authorizer.go new file mode 100644 index 000000000..a3b8075a0 --- /dev/null +++ b/workspace/authorizer.go @@ -0,0 +1,32 @@ +package workspace + +import ( + "context" + + "github.com/go-logr/logr" + "github.com/leg100/otf" + "github.com/leg100/otf/rbac" +) + +// authorizer authorizes access to a workspace +type authorizer struct { + logr.Logger + + db *pgdb +} + +func (a *authorizer) CanAccess(ctx context.Context, action rbac.Action, workspaceID string) (otf.Subject, error) { + subj, err := otf.SubjectFromContext(ctx) + if err != nil { + return nil, err + } + policy, err := a.db.GetWorkspacePolicy(ctx, workspaceID) + if err != nil { + return nil, otf.ErrResourceNotFound + } + if subj.CanAccessWorkspace(action, policy) { + return subj, nil + } + a.Error(nil, "unauthorized action", "workspace", workspaceID, "organization", policy.Organization, "action", action, "subject", subj) + return nil, otf.ErrAccessNotPermitted +} diff --git a/workspace/db.go b/workspace/db.go index 220ccf869..8cb3d03b3 100644 --- a/workspace/db.go +++ b/workspace/db.go @@ -13,7 +13,7 @@ import ( ) type ( - // pgdb is a state/state-version database on postgres + // pgdb is a workspace database on postgres pgdb struct { otf.DB // provides access to generated SQL queries } @@ -47,6 +47,7 @@ type ( Branch pgtype.Text `json:"branch"` LockUsername pgtype.Text `json:"lock_username"` CurrentStateVersionID pgtype.Text `json:"current_state_version_id"` + Tags []string `json:"tags"` LatestRunStatus pgtype.Text `json:"latest_run_status"` UserLock *pggen.Users `json:"user_lock"` RunLock *pggen.Runs `json:"run_lock"` @@ -80,6 +81,7 @@ func (r pgresult) toWorkspace() (*Workspace, error) { TriggerPrefixes: r.TriggerPrefixes, WorkingDirectory: r.WorkingDirectory.String, Organization: r.OrganizationName.String, + Tags: r.Tags, } if r.WorkspaceConnection != nil { @@ -193,20 +195,27 @@ func (db *pgdb) list(ctx context.Context, opts ListOptions) (*WorkspaceList, err // Organization name filter is optional - if not provided use a % which in // SQL means match any organization. - var organizationName string + organization := "%" if opts.Organization != nil { - organizationName = *opts.Organization - } else { - organizationName = "%" + organization = *opts.Organization + } + tags := []string{} + if len(opts.Tags) > 0 { + tags = opts.Tags } db.FindWorkspacesBatch(batch, pggen.FindWorkspacesParams{ - OrganizationNames: []string{organizationName}, + OrganizationNames: []string{organization}, Prefix: sql.String(opts.Prefix), + Tags: tags, Limit: opts.GetLimit(), Offset: opts.GetOffset(), }) - db.CountWorkspacesBatch(batch, sql.String(opts.Prefix), []string{organizationName}) + db.CountWorkspacesBatch(batch, pggen.CountWorkspacesParams{ + Prefix: sql.String(opts.Prefix), + OrganizationNames: []string{organization}, + Tags: tags, + }) results := db.SendBatch(ctx, batch) defer results.Close() @@ -230,7 +239,7 @@ func (db *pgdb) list(ctx context.Context, opts ListOptions) (*WorkspaceList, err return &WorkspaceList{ Items: items, - Pagination: otf.NewPagination(opts.ListOptions, *count), + Pagination: otf.NewPagination(opts.ListOptions, count), }, nil } @@ -285,7 +294,7 @@ func (db *pgdb) listByUsername(ctx context.Context, username string, organizatio return &WorkspaceList{ Items: items, - Pagination: otf.NewPagination(opts, *count), + Pagination: otf.NewPagination(opts, count), }, nil } diff --git a/workspace/lock_service.go b/workspace/lock_service.go index a8c985f33..aecd5c56f 100644 --- a/workspace/lock_service.go +++ b/workspace/lock_service.go @@ -26,7 +26,7 @@ func (s *service) LockWorkspace(ctx context.Context, workspaceID string, runID * id = *runID kind = RunLock } else { - subject, err := s.workspace.CanAccess(ctx, rbac.LockWorkspaceAction, workspaceID) + subject, err := s.CanAccess(ctx, rbac.LockWorkspaceAction, workspaceID) if err != nil { return nil, err } @@ -70,7 +70,7 @@ func (s *service) UnlockWorkspace(ctx context.Context, workspaceID string, runID } else { action = rbac.UnlockWorkspaceAction } - subject, err := s.workspace.CanAccess(ctx, action, workspaceID) + subject, err := s.CanAccess(ctx, action, workspaceID) if err != nil { return nil, err } diff --git a/policy/db.go b/workspace/permissions_db.go similarity index 50% rename from policy/db.go rename to workspace/permissions_db.go index cfde77298..7140a9ad5 100644 --- a/policy/db.go +++ b/workspace/permissions_db.go @@ -1,4 +1,4 @@ -package policy +package workspace import ( "context" @@ -9,11 +9,7 @@ import ( "github.com/leg100/otf/sql/pggen" ) -type pgdb struct { - otf.DB -} - -func (db *pgdb) setWorkspacePermission(ctx context.Context, workspaceID, team string, role rbac.Role) error { +func (db *pgdb) SetWorkspacePermission(ctx context.Context, workspaceID, team string, role rbac.Role) error { _, err := db.UpsertWorkspacePermission(ctx, pggen.UpsertWorkspacePermissionParams{ WorkspaceID: sql.String(workspaceID), TeamName: sql.String(team), @@ -25,33 +21,38 @@ func (db *pgdb) setWorkspacePermission(ctx context.Context, workspaceID, team st return nil } -func (db *pgdb) getWorkspacePolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) { - ws, err := db.FindWorkspaceByID(ctx, sql.String(workspaceID)) - if err != nil { - return otf.WorkspacePolicy{}, sql.Error(err) - } - rows, err := db.FindWorkspacePermissions(ctx, sql.String(workspaceID)) +func (db *pgdb) GetWorkspacePolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) { + result, err := db.FindWorkspacePolicyByID(ctx, sql.String(workspaceID)) if err != nil { return otf.WorkspacePolicy{}, sql.Error(err) } policy := otf.WorkspacePolicy{ - Organization: ws.OrganizationName.String, - WorkspaceID: ws.WorkspaceID.String, + Organization: result.OrganizationName.String, + WorkspaceID: result.WorkspaceID.String, } - for _, perm := range rows { + // SQL query returns an array of workspace permissions and an array of + // teams; the former has the team id, but we need the team name, so + // lookup the corresponding team name in the array of teams. + for _, perm := range result.WorkspacePermissions { role, err := rbac.WorkspaceRoleFromString(perm.Role.String) if err != nil { return otf.WorkspacePolicy{}, err } - policy.Permissions = append(policy.Permissions, otf.WorkspacePermission{ - Team: perm.TeamName.String, - Role: role, - }) + // find corresponding team name in teams array + for _, t := range result.Teams { + if t.TeamID == perm.TeamID { + policy.Permissions = append(policy.Permissions, otf.WorkspacePermission{ + Team: t.Name.String, + Role: role, + }) + break + } + } } return policy, nil } -func (db *pgdb) unsetWorkspacePermission(ctx context.Context, workspaceID, team string) error { +func (db *pgdb) UnsetWorkspacePermission(ctx context.Context, workspaceID, team string) error { _, err := db.DeleteWorkspacePermissionByID(ctx, sql.String(workspaceID), sql.String(team)) if err != nil { return sql.Error(err) diff --git a/workspace/permissions_service.go b/workspace/permissions_service.go new file mode 100644 index 000000000..6d0bee2c9 --- /dev/null +++ b/workspace/permissions_service.go @@ -0,0 +1,53 @@ +package workspace + +import ( + "context" + + "github.com/leg100/otf" + "github.com/leg100/otf/rbac" +) + +type PermissionsService interface { + GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) + + SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error + UnsetPermission(ctx context.Context, workspaceID, team string) error +} + +// GetPolicy retrieves a workspace policy. +// +// NOTE: no authz protects this endpoint because it's used in the process of making +// authz decisions. +func (s *service) GetPolicy(ctx context.Context, workspaceID string) (otf.WorkspacePolicy, error) { + return s.db.GetWorkspacePolicy(ctx, workspaceID) +} + +func (s *service) SetPermission(ctx context.Context, workspaceID, team string, role rbac.Role) error { + subject, err := s.CanAccess(ctx, rbac.SetWorkspacePermissionAction, workspaceID) + if err != nil { + return err + } + + if err := s.db.SetWorkspacePermission(ctx, workspaceID, team, role); err != nil { + s.Error(err, "setting workspace permission", "subject", subject, "workspace", workspaceID) + return err + } + + s.V(0).Info("set workspace permission", "team", team, "role", role, "subject", subject, "workspace", workspaceID) + + // TODO: publish event + + return nil +} + +func (s *service) UnsetPermission(ctx context.Context, workspaceID, team string) error { + subject, err := s.CanAccess(ctx, rbac.UnsetWorkspacePermissionAction, workspaceID) + if err != nil { + s.Error(err, "unsetting workspace permission", "team", team, "subject", subject, "workspace", workspaceID) + return err + } + + s.V(0).Info("unset workspace permission", "team", team, "subject", subject, "workspace", workspaceID) + // TODO: publish event + return s.db.UnsetWorkspacePermission(ctx, workspaceID, team) +} diff --git a/workspace/service.go b/workspace/service.go index 11b93a135..3be0a30ae 100644 --- a/workspace/service.go +++ b/workspace/service.go @@ -12,7 +12,6 @@ import ( "github.com/leg100/otf/auth" "github.com/leg100/otf/http/html" "github.com/leg100/otf/organization" - "github.com/leg100/otf/policy" "github.com/leg100/otf/rbac" "github.com/leg100/otf/repo" "github.com/leg100/otf/vcsprovider" @@ -41,15 +40,17 @@ type ( disconnect(ctx context.Context, workspaceID string) error LockService + PermissionsService + TagService } service struct { logr.Logger otf.Publisher - site otf.Authorizer - organization otf.Authorizer - workspace otf.Authorizer // workspace authorizer + site otf.Authorizer + organization otf.Authorizer + otf.Authorizer // workspace authorizer db *pgdb repo repo.RepoService @@ -66,26 +67,27 @@ type ( repo.RepoService auth.TeamService logr.Logger - WorkspaceAuthorizer otf.Authorizer - policy.PolicyService } ) func NewService(opts Options) *service { + db := &pgdb{opts.DB} svc := service{ - Logger: opts.Logger, - Publisher: opts.Broker, - db: &pgdb{opts.DB}, + Logger: opts.Logger, + Publisher: opts.Broker, + Authorizer: &authorizer{ + Logger: opts.Logger, + db: db, + }, + db: db, repo: opts.RepoService, - site: &otf.SiteAuthorizer{Logger: opts.Logger}, organization: &organization.Authorizer{Logger: opts.Logger}, - workspace: opts.WorkspaceAuthorizer, + site: &otf.SiteAuthorizer{Logger: opts.Logger}, } svc.web = &webHandlers{ Renderer: opts.Renderer, TeamService: opts.TeamService, VCSProviderService: opts.VCSProviderService, - PolicyService: opts.PolicyService, svc: &svc, } // Register with broker so that it can relay workspace events @@ -95,6 +97,7 @@ func NewService(opts Options) *service { func (s *service) AddHandlers(r *mux.Router) { s.web.addHandlers(r) + s.web.addTagHandlers(r) } func (s *service) CreateWorkspace(ctx context.Context, opts CreateOptions) (*Workspace, error) { @@ -125,6 +128,14 @@ func (s *service) CreateWorkspace(ctx context.Context, opts CreateOptions) (*Wor } ws.Connection = conn } + // Optionally create tags within same transaction + if len(opts.Tags) > 0 { + added, err := addTags(ctx, tx, ws, opts.Tags) + if err != nil { + return err + } + ws.Tags = added + } return nil }) if err != nil { @@ -145,7 +156,7 @@ func (s *service) GetByID(ctx context.Context, workspaceID string) (any, error) } func (s *service) GetWorkspace(ctx context.Context, workspaceID string) (*Workspace, error) { - subject, err := s.workspace.CanAccess(ctx, rbac.GetWorkspaceAction, workspaceID) + subject, err := s.CanAccess(ctx, rbac.GetWorkspaceAction, workspaceID) if err != nil { return nil, err } @@ -168,7 +179,7 @@ func (s *service) GetWorkspaceByName(ctx context.Context, organization, workspac return nil, err } - subject, err := s.workspace.CanAccess(ctx, rbac.GetWorkspaceAction, ws.ID) + subject, err := s.CanAccess(ctx, rbac.GetWorkspaceAction, ws.ID) if err != nil { return nil, err } @@ -211,7 +222,7 @@ func (s *service) ListWorkspacesByRepoID(ctx context.Context, repoID uuid.UUID) } func (s *service) UpdateWorkspace(ctx context.Context, workspaceID string, opts UpdateOptions) (*Workspace, error) { - subject, err := s.workspace.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID) + subject, err := s.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID) if err != nil { return nil, err } @@ -237,7 +248,7 @@ func (s *service) UpdateWorkspace(ctx context.Context, workspaceID string, opts } func (s *service) DeleteWorkspace(ctx context.Context, workspaceID string) (*Workspace, error) { - subject, err := s.workspace.CanAccess(ctx, rbac.DeleteWorkspaceAction, workspaceID) + subject, err := s.CanAccess(ctx, rbac.DeleteWorkspaceAction, workspaceID) if err != nil { return nil, err } @@ -270,7 +281,7 @@ func (s *service) DeleteWorkspace(ctx context.Context, workspaceID string) (*Wor // connect connects the workspace to a repo. func (s *service) connect(ctx context.Context, workspaceID string, opts ConnectOptions) (*repo.Connection, error) { - _, err := s.workspace.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID) + _, err := s.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID) if err != nil { return nil, err } @@ -303,7 +314,7 @@ func (s *service) connectWithoutAuthz(ctx context.Context, workspaceID string, t } func (s *service) disconnect(ctx context.Context, workspaceID string) error { - subject, err := s.workspace.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID) + subject, err := s.CanAccess(ctx, rbac.UpdateWorkspaceAction, workspaceID) if err != nil { return err } diff --git a/workspace/tag.go b/workspace/tag.go new file mode 100644 index 000000000..95a49caae --- /dev/null +++ b/workspace/tag.go @@ -0,0 +1,58 @@ +package workspace + +import ( + "errors" + + "github.com/leg100/otf" + "golang.org/x/exp/slog" +) + +var ErrInvalidTagSpec = errors.New("invalid tag spec: must provide either an ID or a name") + +type ( + // Tag is a symbol associated with one or more workspaces. Helps searching and + // grouping workspaces. + Tag struct { + ID string // ID of the form 'tag-*'. Globally unique. + Name string // Meaningful symbol. Unique to an organization. + InstanceCount int // Number of workspaces that have this tag + Organization string // Organization this tag belongs to. + } + + // TagList is a list of tags. + TagList struct { + *otf.Pagination + Items []*Tag + } + + // TagSpec specifies a tag. Either ID or Name must be non-nil for it to + // valid. + TagSpec struct { + ID string + Name string + } + + TagSpecs []TagSpec +) + +func (s TagSpec) Valid() error { + if s.ID == "" && s.Name == "" { + return ErrInvalidTagSpec + } + return nil +} + +func (specs TagSpecs) LogValue() slog.Value { + var ( + ids []string + names []string + ) + for _, s := range specs { + ids = append(ids, s.ID) + names = append(names, s.Name) + } + return slog.GroupValue( + slog.Any("ids", ids), + slog.Any("names", names), + ) +} diff --git a/workspace/tag_db.go b/workspace/tag_db.go new file mode 100644 index 000000000..e45cc80be --- /dev/null +++ b/workspace/tag_db.go @@ -0,0 +1,171 @@ +package workspace + +import ( + "context" + + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/leg100/otf" + "github.com/leg100/otf/sql" + "github.com/leg100/otf/sql/pggen" +) + +type ( + // pgresult represents the result of a database query for a tag. + tagresult struct { + TagID pgtype.Text `json:"tag_id"` + Name pgtype.Text `json:"name"` + OrganizationName pgtype.Text `json:"organization_name"` + InstanceCount int `json:"instance_count"` + } +) + +// toTag converts a database result into a tag +func (r tagresult) toTag() *Tag { + return &Tag{ + ID: r.TagID.String, + Name: r.Name.String, + Organization: r.OrganizationName.String, + InstanceCount: r.InstanceCount, + } +} + +func (db *pgdb) listTags(ctx context.Context, organization string, opts ListTagsOptions) (*TagList, error) { + batch := &pgx.Batch{} + + db.FindTagsBatch(batch, pggen.FindTagsParams{ + OrganizationName: sql.String(organization), + Limit: opts.GetLimit(), + Offset: opts.GetOffset(), + }) + db.CountTagsBatch(batch, sql.String(organization)) + results := db.SendBatch(ctx, batch) + defer results.Close() + + rows, err := db.FindTagsScan(results) + if err != nil { + return nil, sql.Error(err) + } + count, err := db.CountTagsScan(results) + if err != nil { + return nil, sql.Error(err) + } + + var items []*Tag + for _, r := range rows { + items = append(items, tagresult(r).toTag()) + } + + return &TagList{ + Items: items, + Pagination: otf.NewPagination(opts.ListOptions, count), + }, nil +} + +func (db *pgdb) deleteTags(ctx context.Context, organization string, tagIDs []string) error { + err := db.Tx(ctx, func(tx otf.DB) error { + for _, tid := range tagIDs { + _, err := tx.DeleteTag(ctx, sql.String(tid), sql.String(organization)) + if err != nil { + return err + } + } + return nil + }) + return sql.Error(err) +} + +func (db *pgdb) addTag(ctx context.Context, organization, name, id string) error { + _, err := db.InsertTag(ctx, pggen.InsertTagParams{ + TagID: sql.String(id), + Name: sql.String(name), + OrganizationName: sql.String(organization), + }) + if err != nil { + return sql.Error(err) + } + return nil +} + +func (db *pgdb) deleteTag(ctx context.Context, tag *Tag) error { + _, err := db.DeleteTag(ctx, sql.String(tag.ID), sql.String(tag.Organization)) + if err != nil { + return sql.Error(err) + } + return nil +} + +func (db *pgdb) findTagByName(ctx context.Context, organization, name string) (*Tag, error) { + tag, err := db.FindTagByName(ctx, sql.String(name), sql.String(organization)) + if err != nil { + return nil, sql.Error(err) + } + return tagresult(tag).toTag(), nil +} + +func (db *pgdb) findTagByID(ctx context.Context, organization, id string) (*Tag, error) { + tag, err := db.FindTagByID(ctx, sql.String(id), sql.String(organization)) + if err != nil { + return nil, sql.Error(err) + } + return tagresult(tag).toTag(), nil +} + +func (db *pgdb) tagWorkspace(ctx context.Context, workspaceID, tagID string) error { + _, err := db.InsertWorkspaceTag(ctx, sql.String(tagID), sql.String(workspaceID)) + if err != nil { + return sql.Error(err) + } + return nil +} + +func (db *pgdb) deleteWorkspaceTag(ctx context.Context, workspaceID, tagID string) error { + _, err := db.DeleteWorkspaceTag(ctx, sql.String(workspaceID), sql.String(tagID)) + if err != nil { + return sql.Error(err) + } + return nil +} + +func (db *pgdb) listWorkspaceTags(ctx context.Context, workspaceID string, opts ListWorkspaceTagsOptions) (*TagList, error) { + batch := &pgx.Batch{} + + db.FindWorkspaceTagsBatch(batch, pggen.FindWorkspaceTagsParams{ + WorkspaceID: sql.String(workspaceID), + Limit: opts.GetLimit(), + Offset: opts.GetOffset(), + }) + db.CountTagsBatch(batch, sql.String(workspaceID)) + results := db.SendBatch(ctx, batch) + defer results.Close() + + rows, err := db.FindWorkspaceTagsScan(results) + if err != nil { + return nil, sql.Error(err) + } + count, err := db.CountWorkspaceTagsScan(results) + if err != nil { + return nil, sql.Error(err) + } + + var items []*Tag + for _, r := range rows { + items = append(items, tagresult(r).toTag()) + } + + return &TagList{ + Items: items, + Pagination: otf.NewPagination(opts.ListOptions, count), + }, nil +} + +// lockTags tags table within a transaction, providing a callback within which +// caller can use the transaction. +func (db *pgdb) lockTags(ctx context.Context, callback func(*pgdb) error) error { + return db.Tx(ctx, func(tx otf.DB) error { + if _, err := tx.Exec(ctx, "LOCK tags"); err != nil { + return err + } + return callback(&pgdb{tx}) + }) +} diff --git a/workspace/tag_service.go b/workspace/tag_service.go new file mode 100644 index 000000000..25862777e --- /dev/null +++ b/workspace/tag_service.go @@ -0,0 +1,262 @@ +package workspace + +import ( + "context" + "errors" + "fmt" + + "github.com/leg100/otf" + "github.com/leg100/otf/rbac" +) + +type ( + TagService interface { + // ListTags lists tags within an organization + ListTags(ctx context.Context, organization string, opts ListTagsOptions) (*TagList, error) + + // DeleteTags deletes tags from an organization + DeleteTags(ctx context.Context, organization string, tagIDs []string) error + + // TagWorkspaces adds an existing tag to a list of workspaces + TagWorkspaces(ctx context.Context, tagID string, workspaceIDs []string) error + + // AddTags appends tags to a workspace. Any tag specified by ID must + // exist. Any tag specified by name is created if it does not + // exist. + AddTags(ctx context.Context, workspaceID string, tags []TagSpec) error + + // RemoveTags removes tags from a workspace. The workspace must already + // exist. Any tag specifying an ID must exist. Any tag specifying a name + // need not exist and no action is taken. If a tag is no longer + // associated with any workspaces it is removed. + RemoveTags(ctx context.Context, workspaceID string, tags []TagSpec) error + + // ListWorkspaceTags lists the tags for a workspace. + ListWorkspaceTags(ctx context.Context, workspaceID string, options ListWorkspaceTagsOptions) (*TagList, error) + + listAllTags(ctx context.Context, organization string) ([]*Tag, error) + } + + // ListTagsOptions are options for paginating and filtering a list of + // tags + ListTagsOptions struct { + otf.ListOptions + } + + // ListWorkspaceTagsOptions are options for paginating and filtering a list of + // workspace tags + ListWorkspaceTagsOptions struct { + otf.ListOptions + } +) + +func (s *service) ListTags(ctx context.Context, organization string, opts ListTagsOptions) (*TagList, error) { + subject, err := s.organization.CanAccess(ctx, rbac.ListTagsAction, organization) + if err != nil { + return nil, err + } + + list, err := s.db.listTags(ctx, organization, opts) + if err != nil { + s.Error(err, "listing tags", "organization", organization, "subject", subject) + } + s.V(9).Info("listed tags", "organization", organization, "subject", subject) + return list, nil +} + +func (s *service) DeleteTags(ctx context.Context, organization string, tagIDs []string) error { + subject, err := s.organization.CanAccess(ctx, rbac.DeleteTagsAction, organization) + if err != nil { + return err + } + + if err := s.db.deleteTags(ctx, organization, tagIDs); err != nil { + s.Error(err, "deleting tags", "organization", organization, "tags_ids", tagIDs, "subject", subject) + return err + } + s.Info("deleted tags", "organization", organization, "tag_ids", tagIDs, "subject", subject) + return nil +} + +func (s *service) TagWorkspaces(ctx context.Context, tagID string, workspaceIDs []string) error { + subject, err := otf.SubjectFromContext(ctx) + if err != nil { + return err + } + + err = s.db.tx(ctx, func(tx *pgdb) error { + for _, wid := range workspaceIDs { + _, err := s.CanAccess(ctx, rbac.TagWorkspacesAction, wid) + if err != nil { + return err + } + if err := tx.tagWorkspace(ctx, wid, tagID); err != nil { + return err + } + } + return nil + }) + if err != nil { + s.Error(err, "tagging tags", "tag_id", tagID, "workspace_ids", workspaceIDs, "subject", subject) + return err + } + s.Info("tagged workspaces", "tag_id", tagID, "workspaces_ids", workspaceIDs, "subject", subject) + return nil +} + +func (s *service) AddTags(ctx context.Context, workspaceID string, tags []TagSpec) error { + subject, err := s.CanAccess(ctx, rbac.AddTagsAction, workspaceID) + if err != nil { + return err + } + + ws, err := s.db.get(ctx, workspaceID) + if err != nil { + return fmt.Errorf("workspace not found; %s; %w", workspaceID, err) + } + + added, err := addTags(ctx, s.db, ws, tags) + if err != nil { + s.Error(err, "adding tags", "workspace", workspaceID, "tags", TagSpecs(tags), "subject", subject) + return err + } + s.Info("added tags", "workspace", workspaceID, "tags", added, "subject", subject) + return nil +} + +func (s *service) RemoveTags(ctx context.Context, workspaceID string, tags []TagSpec) error { + subject, err := s.CanAccess(ctx, rbac.RemoveTagsAction, workspaceID) + if err != nil { + return err + } + + ws, err := s.db.get(ctx, workspaceID) + if err != nil { + return fmt.Errorf("workspace not found; %s; %w", workspaceID, err) + } + + err = s.db.lockTags(ctx, func(tx *pgdb) (err error) { + for _, t := range tags { + if err := t.Valid(); err != nil { + return err + } + var tag *Tag + + switch { + case t.Name != "": + tag, err = tx.findTagByName(ctx, ws.Organization, t.Name) + if errors.Is(err, otf.ErrResourceNotFound) { + // ignore tags that cannot be found when specified by name + continue + } else if err != nil { + return err + } + case t.ID != "": + tag, err = tx.findTagByID(ctx, ws.Organization, t.ID) + if err != nil { + return err + } + default: + return ErrInvalidTagSpec + } + if err := tx.deleteWorkspaceTag(ctx, workspaceID, tag.ID); err != nil { + return fmt.Errorf("removing tag %s from workspace %s: %w", tag.ID, workspaceID, err) + } + // Delete tag if it is no longer associated with any workspaces. If + // that is the case then instance count should be 1, since its last + // workspace has just been deleted. + if tag.InstanceCount == 1 { + if err := tx.deleteTag(ctx, tag); err != nil { + return fmt.Errorf("deleting tag: %w", err) + } + } + } + return nil + }) + if err != nil { + s.Error(err, "removing tags", "workspace", workspaceID, "tags", TagSpecs(tags), "subject", subject) + return err + } + s.Info("removed tags", "workspace", workspaceID, "tags", TagSpecs(tags), "subject", subject) + return nil +} + +func (s *service) ListWorkspaceTags(ctx context.Context, workspaceID string, opts ListWorkspaceTagsOptions) (*TagList, error) { + subject, err := s.CanAccess(ctx, rbac.ListWorkspaceTags, workspaceID) + if err != nil { + return nil, err + } + + list, err := s.db.listWorkspaceTags(ctx, workspaceID, opts) + if err != nil { + s.Error(err, "listing workspace tags", "workspace", workspaceID, "subject", subject) + return nil, err + } + s.V(9).Info("listed workspace tags", "workspace", workspaceID, "subject", subject) + return list, nil +} + +func (s *service) listAllTags(ctx context.Context, organization string) ([]*Tag, error) { + var ( + tags []*Tag + opts ListTagsOptions + ) + for { + page, err := s.ListTags(ctx, organization, opts) + if err != nil { + return nil, err + } + tags = append(tags, page.Items...) + if page.NextPage() == nil { + break + } + opts.PageNumber = *page.NextPage() + } + return tags, nil +} + +func addTags(ctx context.Context, db *pgdb, ws *Workspace, tags []TagSpec) ([]string, error) { + // For each tag: + // (i) if specified by name, create new tag if it does not exist and get its ID. + // (ii) add tag to workspace + var added []string + err := db.lockTags(ctx, func(tx *pgdb) (err error) { + for _, t := range tags { + if err := t.Valid(); err != nil { + return err + } + + id := t.ID + name := t.Name + switch { + case name != "": + existing, err := tx.findTagByName(ctx, ws.Organization, name) + if errors.Is(err, otf.ErrResourceNotFound) { + id = otf.NewID("tag") + if err := tx.addTag(ctx, ws.Organization, name, id); err != nil { + return err + } + } else if err != nil { + return err + } else { + id = existing.ID + } + case id != "": + existing, err := tx.findTagByID(ctx, ws.Organization, t.ID) + if err != nil { + return err + } + name = existing.Name + default: + return ErrInvalidTagSpec + } + + if err := tx.tagWorkspace(ctx, ws.ID, id); err != nil { + return err + } + added = append(added, name) + } + return nil + }) + return added, err +} diff --git a/workspace/tags_web.go b/workspace/tags_web.go new file mode 100644 index 000000000..0a005d82c --- /dev/null +++ b/workspace/tags_web.go @@ -0,0 +1,57 @@ +package workspace + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/leg100/otf/http/decode" + "github.com/leg100/otf/http/html" + "github.com/leg100/otf/http/html/paths" +) + +func (h *webHandlers) addTagHandlers(r *mux.Router) { + r = html.UIRouter(r) + + r.HandleFunc("/workspaces/{workspace_id}/create-tag", h.createTag).Methods("POST") + r.HandleFunc("/workspaces/{workspace_id}/delete-tag", h.deleteTag).Methods("POST") +} + +func (h *webHandlers) createTag(w http.ResponseWriter, r *http.Request) { + var params struct { + WorkspaceID *string `schema:"workspace_id,required"` + TagName *string `schema:"tag_name,required"` + } + if err := decode.All(¶ms, r); err != nil { + html.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + err := h.svc.AddTags(r.Context(), *params.WorkspaceID, []TagSpec{{Name: *params.TagName}}) + if err != nil { + html.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + html.FlashSuccess(w, "created tag: "+*params.TagName) + http.Redirect(w, r, paths.EditWorkspace(*params.WorkspaceID), http.StatusFound) +} + +func (h *webHandlers) deleteTag(w http.ResponseWriter, r *http.Request) { + var params struct { + WorkspaceID *string `schema:"workspace_id,required"` + TagName *string `schema:"tag_name,required"` + } + if err := decode.All(¶ms, r); err != nil { + html.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + err := h.svc.RemoveTags(r.Context(), *params.WorkspaceID, []TagSpec{{Name: *params.TagName}}) + if err != nil { + html.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + html.FlashSuccess(w, "removed tag: "+*params.TagName) + http.Redirect(w, r, paths.EditWorkspace(*params.WorkspaceID), http.StatusFound) +} diff --git a/workspace/test_helpers.go b/workspace/test_helpers.go index cc22974c1..427420e03 100644 --- a/workspace/test_helpers.go +++ b/workspace/test_helpers.go @@ -8,7 +8,6 @@ import ( "github.com/leg100/otf/auth" "github.com/leg100/otf/cloud" "github.com/leg100/otf/http/html" - "github.com/leg100/otf/policy" "github.com/leg100/otf/repo" "github.com/leg100/otf/vcsprovider" "github.com/stretchr/testify/require" @@ -26,7 +25,6 @@ type ( auth.TeamService VCSProviderService - policy.PolicyService } fakeWebServiceOption func(*fakeWebService) @@ -75,7 +73,6 @@ func fakeWebHandlers(t *testing.T, opts ...fakeWebServiceOption) *webHandlers { Renderer: renderer, TeamService: &svc, VCSProviderService: &svc, - PolicyService: &svc, svc: &svc, } } @@ -147,6 +144,10 @@ func (f *fakeWebService) disconnect(context.Context, string) error { return nil } +func (f *fakeWebService) listAllTags(ctx context.Context, organization string) ([]*Tag, error) { + return nil, nil +} + type fakeWebCloudClient struct { repos []string diff --git a/workspace/web.go b/workspace/web.go index 86a0e08bd..7c877fb69 100644 --- a/workspace/web.go +++ b/workspace/web.go @@ -12,7 +12,6 @@ import ( "github.com/leg100/otf/http/html" "github.com/leg100/otf/http/html/paths" "github.com/leg100/otf/organization" - "github.com/leg100/otf/policy" "github.com/leg100/otf/rbac" "github.com/leg100/otf/vcsprovider" ) @@ -22,7 +21,6 @@ type ( html.Renderer auth.TeamService VCSProviderService - policy.PolicyService svc Service } @@ -66,32 +64,49 @@ func (h *webHandlers) addHandlers(r *mux.Router) { } func (h *webHandlers) listWorkspaces(w http.ResponseWriter, r *http.Request) { - var params struct { - Organization string `schema:"organization_name,required"` - otf.ListOptions // Pagination - } + var params ListOptions if err := decode.All(¶ms, r); err != nil { html.Error(w, err.Error(), http.StatusUnprocessableEntity) return } - workspaces, err := h.svc.ListWorkspaces(r.Context(), ListOptions{ - Organization: ¶ms.Organization, - ListOptions: params.ListOptions, - }) + workspaces, err := h.svc.ListWorkspaces(r.Context(), params) if err != nil { html.Error(w, err.Error(), http.StatusInternalServerError) return } + // retrieve all tags and create map, with each entry determining whether + // listing is currently filtered by the tag or not. + tags, err := h.svc.listAllTags(r.Context(), *params.Organization) + if err != nil { + html.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tagfilters := func() map[string]bool { + m := make(map[string]bool, len(tags)) + for _, t := range tags { + m[t.Name] = false + for _, f := range params.Tags { + if t.Name == f { + m[t.Name] = true + break + } + } + } + return m + } + h.Render("workspace_list.tmpl", w, struct { organization.OrganizationPage CreateWorkspaceAction rbac.Action *WorkspaceList + TagFilters map[string]bool }{ - OrganizationPage: organization.NewPage(r, "workspaces", params.Organization), + OrganizationPage: organization.NewPage(r, "workspaces", *params.Organization), CreateWorkspaceAction: rbac.CreateWorkspaceAction, WorkspaceList: workspaces, + TagFilters: tagfilters(), }) } @@ -148,7 +163,7 @@ func (h *webHandlers) getWorkspace(w http.ResponseWriter, r *http.Request) { html.Error(w, err.Error(), http.StatusInternalServerError) return } - policy, err := h.GetPolicy(r.Context(), id) + policy, err := h.svc.GetPolicy(r.Context(), id) if err != nil { html.Error(w, err.Error(), http.StatusInternalServerError) return @@ -211,7 +226,7 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) { return } - policy, err := h.GetPolicy(r.Context(), workspaceID) + policy, err := h.svc.GetPolicy(r.Context(), workspaceID) if err != nil { html.Error(w, err.Error(), http.StatusInternalServerError) return @@ -233,16 +248,31 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) { } } + tags, err := h.svc.listAllTags(r.Context(), workspace.Organization) + if err != nil { + html.Error(w, err.Error(), http.StatusInternalServerError) + return + } + getTagNames := func() (names []string) { + for _, t := range tags { + names = append(names, t.Name) + } + return + } + h.Render("workspace_edit.tmpl", w, struct { WorkspacePage Policy otf.WorkspacePolicy Unassigned []*auth.Team Roles []rbac.Role VCSProvider *vcsprovider.VCSProvider + UnassignedTags []string UpdateWorkspaceAction rbac.Action DeleteWorkspaceAction rbac.Action SetWorkspacePermissionAction rbac.Action UnsetWorkspacePermissionAction rbac.Action + AddTagsAction rbac.Action + RemoveTagsAction rbac.Action CreateRunAction rbac.Action }{ WorkspacePage: NewPage(r, "edit | "+workspace.ID, workspace), @@ -255,11 +285,14 @@ func (h *webHandlers) editWorkspace(w http.ResponseWriter, r *http.Request) { rbac.WorkspaceAdminRole, }, VCSProvider: provider, + UnassignedTags: otf.DiffStrings(getTagNames(), workspace.Tags), UpdateWorkspaceAction: rbac.UpdateWorkspaceAction, DeleteWorkspaceAction: rbac.DeleteWorkspaceAction, SetWorkspacePermissionAction: rbac.SetWorkspacePermissionAction, UnsetWorkspacePermissionAction: rbac.UnsetWorkspacePermissionAction, CreateRunAction: rbac.CreateRunAction, + AddTagsAction: rbac.AddTagsAction, + RemoveTagsAction: rbac.RemoveTagsAction, }) } @@ -486,7 +519,7 @@ func (h *webHandlers) setWorkspacePermission(w http.ResponseWriter, r *http.Requ return } - err = h.SetPermission(r.Context(), params.WorkspaceID, params.TeamName, role) + err = h.svc.SetPermission(r.Context(), params.WorkspaceID, params.TeamName, role) if err != nil { html.Error(w, err.Error(), http.StatusInternalServerError) return @@ -505,7 +538,7 @@ func (h *webHandlers) unsetWorkspacePermission(w http.ResponseWriter, r *http.Re return } - err := h.UnsetPermission(r.Context(), params.WorkspaceID, params.TeamName) + err := h.svc.UnsetPermission(r.Context(), params.WorkspaceID, params.TeamName) if err != nil { html.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/workspace/web_test.go b/workspace/web_test.go index b5b372d2d..48fd3f642 100644 --- a/workspace/web_test.go +++ b/workspace/web_test.go @@ -109,8 +109,8 @@ func TestEditWorkspaceHandler(t *testing.T) { user: auth.SiteAdmin, want: func(t *testing.T, doc *html.Node) { // always show built-in owners permission - findText(t, doc, "owners", "//div[@class='permissions-container']//tbody//tr[1]/td[1]") - findText(t, doc, "admin", "//div[@class='permissions-container']//tbody//tr[1]/td[2]") + findText(t, doc, "owners", "//div[@id='permissions-container']//tbody//tr[1]/td[1]") + findText(t, doc, "admin", "//div[@id='permissions-container']//tbody//tr[1]/td[2]") // all buttons should be enabled buttons := htmlquery.Find(doc, `//button`) @@ -411,6 +411,8 @@ func TestFilterUnassigned(t *testing.T) { } func findText(t *testing.T, doc *html.Node, want, selector string) { + t.Helper() + got := htmlquery.FindOne(doc, selector) if assert.NotNil(t, got) { assert.Equal(t, want, htmlquery.InnerText(got)) diff --git a/workspace/workspace.go b/workspace/workspace.go index 0b4a7ebdd..98d484c1d 100644 --- a/workspace/workspace.go +++ b/workspace/workspace.go @@ -51,6 +51,7 @@ type ( Organization string Connection *repo.Connection LatestRun *LatestRun + Tags []string *lock } @@ -85,6 +86,7 @@ type ( SourceName *string SourceURL *string StructuredRunOutputEnabled *bool + Tags []TagSpec TerraformVersion *string TriggerPrefixes []string WorkingDirectory *string @@ -113,9 +115,10 @@ type ( // ListOptions are options for paginating and filtering a list of // Workspaces ListOptions struct { - otf.ListOptions // Pagination - Prefix string `schema:"search[name],omitempty"` - Organization *string `schema:"organization_name,required"` + otf.ListOptions // Pagination + Prefix string `schema:"search[name],omitempty"` + Tags []string `schema:"search[tags],omitempty"` + Organization *string `schema:"organization_name,required"` } ConnectOptions struct {