Skip to content

Commit

Permalink
Workspace Tags (#413)
Browse files Browse the repository at this point in the history
  • Loading branch information
leg100 authored May 4, 2023
1 parent d2986e3 commit 98f00cd
Show file tree
Hide file tree
Showing 68 changed files with 2,678 additions and 450 deletions.
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ anaconda-mode/
Session.vim
# temporary
.netrwhist
# auto-generated tag files
tags
### VisualStudioCode ###
.vscode/*
.history
Expand Down
45 changes: 28 additions & 17 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand All @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 1 addition & 3 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -48,7 +47,6 @@ type (
auth.AuthService
tokens.TokensService
variable.VariableService
policy.PolicyService

*surl.Signer

Expand All @@ -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,
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions api/marshaler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -33,7 +32,6 @@ type (
organization.OrganizationService
state.StateService
workspace.WorkspaceService
policy.PolicyService

*runLogsURLGenerator
}
Expand Down Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions api/tag.go
Original file line number Diff line number Diff line change
@@ -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(&params, 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, &params); 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, &params); 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, &params); 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(&params, 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
}
22 changes: 22 additions & 0 deletions api/tag_marshaler.go
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 26 additions & 0 deletions api/types/tag.go
Original file line number Diff line number Diff line change
@@ -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"`
}
)
5 changes: 5 additions & 0 deletions api/types/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions api/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Loading

0 comments on commit 98f00cd

Please sign in to comment.