Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] replace "state name" with "workspace" #24

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ You can use TerraDB as an HTTP remote backend for Terraform:
```hcl
terraform {
backend "http" {
address = "http://<terradb>:<port>/v1/states/<name>"
lock_address = "http://<terradb>:<port>/v1/states/<name>"
unlock_address = "http://<terradb>:<port>/v1/states/<name>"
address = "http://<terradb>:<port>/v1/states/<workspace>"
lock_address = "http://<terradb>:<port>/v1/states/<workspace>"
unlock_address = "http://<terradb>:<port>/v1/states/<workspace>"
}
}
```

Note: do not use the `/` character in the project name.
Note: do not use the `/` character in the workspace's name.


## API Documentation
Expand All @@ -116,15 +116,15 @@ Returns the latest serial of each state stored in the database, along with its
lock information.


### `/states/{name}`
### `/states/{workspace}`

Returns the latest serial of a single state by its name, along with its lock
Returns the latest serial of a single workspace's state, along with its lock
information.


### `/states/{name}/serials`
### `/states/{workspace}/serials`

Returns all serials of a single state by its name. Lock information is not
Returns all serials of a single workspace's state. Lock information is not
provided.

### `/resources/${state}/${module}/${name}`
Expand Down
13 changes: 7 additions & 6 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,14 @@ func StartServer(cfg *API, st storage.Storage) {
router.Use(s.handleAPIRequest)

apiRtr := router.PathPrefix("/v1").Subrouter()
apiRtr.HandleFunc("/workspace", s.ListWorkspaces).Methods("GET")
apiRtr.HandleFunc("/states", s.ListStates).Methods("GET")
apiRtr.HandleFunc("/states/{name}", s.InsertState).Methods("POST")
apiRtr.HandleFunc("/states/{name}", s.GetState).Methods("GET")
apiRtr.HandleFunc("/states/{name}", s.RemoveState).Methods("DELETE")
apiRtr.HandleFunc("/states/{name}", s.LockState).Methods("LOCK")
apiRtr.HandleFunc("/states/{name}", s.UnlockState).Methods("UNLOCK")
apiRtr.HandleFunc("/states/{name}/serials", s.ListStateSerials).Methods("GET")
apiRtr.HandleFunc("/states/{workspace}", s.InsertState).Methods("POST")
apiRtr.HandleFunc("/states/{workspace}", s.GetState).Methods("GET")
apiRtr.HandleFunc("/states/{workspace}", s.RemoveState).Methods("DELETE")
apiRtr.HandleFunc("/states/{workspace}", s.LockState).Methods("LOCK")
apiRtr.HandleFunc("/states/{workspace}", s.UnlockState).Methods("UNLOCK")
apiRtr.HandleFunc("/states/{workspace}/serials", s.ListStateSerials).Methods("GET")
apiRtr.HandleFunc("/resources/{state}/{module}/{name}", s.GetResource).Methods("GET")
apiRtr.HandleFunc("/resources/{state}/{name}", s.GetResource).Methods("GET")

Expand Down
107 changes: 107 additions & 0 deletions internal/api/workspaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package api

import (
"encoding/json"
"io/ioutil"
"net/http"

"github.com/camptocamp/terradb/internal/storage"
"github.com/gorilla/mux"
)

func (s *server) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
page, pageSize, err := s.parsePagination(r)
if err != nil {
err500(err, "", w)
return
}

coll, err := s.st.ListWorkspaces(page, pageSize)
if err != nil {
err500(err, "failed to retrieve workspaces", w)
return
}

data, err := json.Marshal(coll)
if err != nil {
err500(err, "failed to marshal workspaces", w)
return
}

w.WriteHeader(http.StatusOK)
w.Write(data)
return
}

func (s *server) LockWorkspace(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)

var currentLock, remoteLock storage.LockInfo

body, err := ioutil.ReadAll(r.Body)
if err != nil {
err500(err, "failed to read body", w)
return
}

err = json.Unmarshal(body, &currentLock)
if err != nil {
err500(err, "failed to unmarshal lock", w)
return
}

remoteLock, err = s.st.GetLockStatus(params["name"])
if err == storage.ErrNoDocuments {
err = s.st.LockWorkspace(params["name"], currentLock)
if err != nil {
err500(err, "failed to lock state", w)
return
}

w.WriteHeader(http.StatusOK)
return
} else if err != nil {
err500(err, "failed to get lock status", w)
return
}

if currentLock.ID == remoteLock.ID {
d, _ := json.Marshal(remoteLock)
w.WriteHeader(http.StatusLocked)
w.Write(d)
return
}

d, _ := json.Marshal(remoteLock)
w.WriteHeader(http.StatusConflict)
w.Write(d)
return

}

func (s *server) UnlockWorkspace(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)

var lockData storage.LockInfo

body, err := ioutil.ReadAll(r.Body)
if err != nil {
err500(err, "failed to read body", w)
return
}

err = json.Unmarshal(body, &lockData)
if err != nil {
err500(err, "failed to unmarshal lock", w)
return
}

err = s.st.UnlockWorkspace(params["name"], lockData)
if err != nil {
err500(err, "failed to unlock state", w)
return
}

w.WriteHeader(http.StatusOK)
return
}
96 changes: 96 additions & 0 deletions internal/storage/mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type mongoDoc struct {
Timestamp string
Source string
State *State
Workspace *Workspace
Name string
}

Expand Down Expand Up @@ -111,6 +112,23 @@ func (st *MongoDBStorage) LockState(name string, lockData LockInfo) (err error)
return
}

// LockWorkspace locks a Terraform state.
func (st *MongoDBStorage) LockWorkspace(name string, lockData LockInfo) (err error) {
collection := st.client.Database("terradb").Collection("locks")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Workspace file uses the same key as lock
lockData.Path = name

_, err = collection.InsertOne(ctx, map[string]interface{}{
"name": name,
"lock": lockData,
})

return
}

// UnlockState unlocks a Terraform state.
func (st *MongoDBStorage) UnlockState(name string, lockData LockInfo) (err error) {
collection := st.client.Database("terradb").Collection("locks")
Expand All @@ -124,6 +142,19 @@ func (st *MongoDBStorage) UnlockState(name string, lockData LockInfo) (err error
return
}

// UnlockWorkspace unlocks a Terraform state.
func (st *MongoDBStorage) UnlockWorkspace(name string, lockData LockInfo) (err error) {
collection := st.client.Database("terradb").Collection("locks")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

_, err = collection.DeleteOne(ctx, map[string]interface{}{
"name": name,
}, &options.DeleteOptions{})

return
}

// RemoveState removes the Terraform states.
func (st *MongoDBStorage) RemoveState(name string) (err error) {
collection := st.client.Database("terradb").Collection("terraform_states")
Expand Down Expand Up @@ -192,6 +223,61 @@ func (st *MongoDBStorage) ListStates(pageNum, pageSize int) (coll StateCollectio
return coll, nil
}

// ListWorkspaces returns all workspaces from TerraDB
func (st *MongoDBStorage) ListWorkspaces(pageNum, pageSize int) (coll WorkspaceCollection, err error) {
collection := st.client.Database("terradb").Collection("terraform_workspaces")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req := mongo.Pipeline{
{{"$group", bson.D{
{"_id", "$name"},
{"name", bson.D{{"$last", "$name"}}},
{"state", bson.D{{"$last", "$state"}}},
{"timestamp", bson.D{{"$last", "$timestamp"}}},
}}},
}
pl := paginateReq(req, pageNum, pageSize)
cur, err := collection.Aggregate(ctx, pl, options.Aggregate())
if err != nil {
return coll, fmt.Errorf("failed to list workspaces: %v", err)
}

defer cur.Close(context.Background())

for cur.Next(nil) {
var mongoColl mongoDocCollection
err = cur.Decode(&mongoColl)
if err != nil {
return coll, fmt.Errorf("failed to decode workspace: %v", err)
}
coll.Metadata = mongoColl.Metadata
for _, d := range mongoColl.Docs {
workspace, err := d.toWorkspace()
if err != nil {
return coll, fmt.Errorf("failed to get state: %v", err)
}

workspace.LockInfo, err = st.GetLockStatus(workspace.Name)
// Init value required because of omitempty
workspace.Locked = false
if err == nil {
workspace.Locked = true
} else if err == ErrNoDocuments {
log.WithFields(log.Fields{
"name": workspace.Name,
}).Info("Did not find lock info")
} else {
return coll, fmt.Errorf("failed to retrieve lock for %s: %v", workspace.Name, err)
}
coll.Data = append(coll.Data, workspace)
}
return coll, nil
}

return coll, nil
}

// GetState retrieves a Terraform state, at a given serial.
// If serial is 0, it gets the latest serial
func (st *MongoDBStorage) GetState(name string, serial int) (state State, err error) {
Expand Down Expand Up @@ -361,6 +447,16 @@ func paginateReq(req mongo.Pipeline, pageNum, pageSize int) (pl mongo.Pipeline)
return
}

func (d *mongoDoc) toWorkspace() (workspace *Workspace, err error) {
workspace = d.Workspace
workspace.Name = d.Name
workspace.LastModified, err = time.Parse("20060102150405", d.Timestamp)
if err != nil {
return workspace, fmt.Errorf("failed to convert timestamp: %v", err)
}
return
}

func (d *mongoDoc) toState() (state *State, err error) {
state = d.State
state.Name = d.Name
Expand Down
22 changes: 22 additions & 0 deletions internal/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,25 @@ type StateCollection struct {
Data []*State `json:"data"`
}

// workspaceCollection is a collection of Workspace, with metadata
type WorkspaceCollection struct {
Metadata []*Metadata `json:"metadata"`
Data []*Workspace `json:"data"`
}

// Workspace is a Terraform workspace
type Workspace struct {
LastModified time.Time `json:"last_modified"`
Name string `json:"name"`

// Keep Lock info
Locked bool `json:"locked"`
LockInfo LockInfo `json:"lock"`

States []*terraform.State
Plans []*terraform.Plan
}

// LockInfo stores lock metadata.
//
// Copied from Terraform's source code
Expand Down Expand Up @@ -104,13 +123,16 @@ var ErrNoDocuments = errors.New("No document found")
// Storage is an abstraction over database engines
type Storage interface {
GetName() string
ListWorkspaces(pageNum, pageSize int) (coll WorkspaceCollection, err error)
ListStates(pageNum, pageSize int) (coll StateCollection, err error)
GetState(name string, serial int) (state State, err error)
InsertState(document State, timestamp, source, name string) (err error)
RemoveState(name string) (err error)
GetLockStatus(name string) (lockStatus LockInfo, err error)
LockState(name string, lockData LockInfo) (err error)
LockWorkspace(name string, lockData LockInfo) (err error)
UnlockState(name string, lockData LockInfo) (err error)
UnlockWorkspace(name string, lockData LockInfo) (err error)
ListStateSerials(name string, pageNum, pageSize int) (coll StateCollection, err error)
GetResource(state, module, name string) (res Resource, err error)
}
10 changes: 10 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ func (c *Client) ListStates() (states storage.StateCollection, err error) {
return
}

// ListWorkspaces lists all workspaces in TerraDB.
func (c *Client) ListWorkspaces() (workspaces storage.WorkspaceCollection, err error) {
err = c.get(&workspaces, "workspaces", nil)
if err != nil {
return workspaces, fmt.Errorf("failed to retrieve workspaces: %v", err)
}

return
}

// GetState returns a TerraDB state from its name and serial.
// Use 0 as serial to return the latest version of the state.
func (c *Client) GetState(name string, serial int) (st storage.State, err error) {
Expand Down