Skip to content

Commit

Permalink
Add deployment environment API endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
dotNomad committed Oct 28, 2024
1 parent fdc5cc0 commit b525971
Show file tree
Hide file tree
Showing 8 changed files with 308 additions and 0 deletions.
1 change: 1 addition & 0 deletions internal/clients/connect/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type APIClient interface {
ContentDetails(contentID types.ContentID, body *ConnectContent, log logging.Logger) error
CreateDeployment(*ConnectContent, logging.Logger) (types.ContentID, error)
UpdateDeployment(types.ContentID, *ConnectContent, logging.Logger) error
GetEnvVars(types.ContentID, logging.Logger) (*types.Environment, error)
SetEnvVars(types.ContentID, config.Environment, logging.Logger) error
UploadBundle(types.ContentID, io.Reader, logging.Logger) (types.BundleID, error)
DeployBundle(types.ContentID, types.BundleID, logging.Logger) (types.TaskID, error)
Expand Down
10 changes: 10 additions & 0 deletions internal/clients/connect/client_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,16 @@ func (c *ConnectClient) UpdateDeployment(contentID types.ContentID, body *Connec
return c.client.Patch(url, body, nil, log)
}

func (c *ConnectClient) GetEnvVars(contentId types.ContentID, log logging.Logger) (*types.Environment, error) {
var env *types.Environment
url := fmt.Sprintf("/__api__/v1/content/%s/environment", contentId)
err := c.client.Get(url, &env, log)
if err != nil {
return nil, err
}
return env, nil
}

type connectEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
Expand Down
10 changes: 10 additions & 0 deletions internal/clients/connect/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ func (m *MockClient) UpdateDeployment(id types.ContentID, s *ConnectContent, log
return args.Error(0)
}

func (m *MockClient) GetEnvVars(id types.ContentID, log logging.Logger) (*types.Environment, error) {
args := m.Called(id, log)
env := args.Get(0)
if env == nil {
return nil, args.Error(1)
} else {
return env.(*types.Environment), args.Error(1)
}
}

func (m *MockClient) SetEnvVars(id types.ContentID, env config.Environment, log logging.Logger) error {
args := m.Called(id, env, log)
return args.Error(0)
Expand Down
4 changes: 4 additions & 0 deletions internal/deployment/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ func ValidateFile(path util.AbsolutePath) error {

const autogenHeader = "# This file is automatically generated by Posit Publisher; do not edit.\n"

func (d *Deployment) IsDeployed() bool {
return d.ID != ""
}

func (d *Deployment) Write(w io.Writer) error {
_, err := w.Write([]byte(autogenHeader))
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/services/api/api_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log
r.Handle(ToPath("deployments", "{name}"), DeleteDeploymentHandlerFunc(base, log)).
Methods(http.MethodDelete)

// GET /api/deployments/$NAME/environment
r.Handle(ToPath("deployments", "{name}", "environment"), GetDeploymentEnvironmentHandlerFunc(base, log, lister)).
Methods(http.MethodGet)

// POST /api/packages/python/scan
r.Handle(ToPath("packages", "python", "scan"), NewPostPackagesPythonScanHandler(base, log)).
Methods(http.MethodPost)
Expand Down
86 changes: 86 additions & 0 deletions internal/services/api/get_deployment_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package api

// Copyright (C) 2024 by Posit Software, PBC.

import (
"errors"
"fmt"
"io/fs"
"net/http"
"time"

"github.com/gorilla/mux"
"github.com/posit-dev/publisher/internal/accounts"
"github.com/posit-dev/publisher/internal/clients/http_client"
"github.com/posit-dev/publisher/internal/deployment"
"github.com/posit-dev/publisher/internal/events"
"github.com/posit-dev/publisher/internal/logging"
"github.com/posit-dev/publisher/internal/util"
)

func GetDeploymentEnvironmentHandlerFunc(base util.AbsolutePath, log logging.Logger, accountList accounts.AccountList) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
name := mux.Vars(req)["name"]
projectDir, _, err := ProjectDirFromRequest(base, w, req, log)
if err != nil {
// Response already returned by ProjectDirFromRequest
return
}

path := deployment.GetDeploymentPath(projectDir, name)
d, err := deployment.FromFile(path)
if err != nil {
// If the deployment file doesn't exist, return a 404
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, req)
return
}
// If the deployment file is in error return a 400
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("deployment %s is invalid: %s", name, err)))
return
}

if !d.IsDeployed() {
// If the deployment file is not deployed, it cannot have
// environment variables on the server so return a 400
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("deployment %s is not deployed", name)))
return
}

account, err := accountList.GetAccountByServerURL(d.ServerURL)
if err != nil {
// If the deployment server URL doesn't have an associated
// credential return a 400
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("no credential found to use with deployment %s", name)))
return
}

client, err := clientFactory(account, 30*time.Second, events.NewNullEmitter(), log)
if err != nil {
// If the client cannot be created, we did something wrong,
// return a 500
InternalError(w, req, log, err)
return
}
env, err := client.GetEnvVars(d.ID, log)
// TODO content on the server could be deleted
if err != nil {
httpErr, ok := err.(*http_client.HTTPError)
if ok {
// Pass through HTTP Error from Connect
w.WriteHeader(httpErr.Status)
w.Write([]byte(httpErr.Error()))
return
}
// If we get anything other than a HTTP Error from Connect client,
// return a 500
InternalError(w, req, log, err)
return
}

JsonResult(w, http.StatusOK, env)
}
}
192 changes: 192 additions & 0 deletions internal/services/api/get_deployment_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package api

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gorilla/mux"
"github.com/posit-dev/publisher/internal/accounts"
"github.com/posit-dev/publisher/internal/clients/connect"
"github.com/posit-dev/publisher/internal/clients/http_client"
"github.com/posit-dev/publisher/internal/deployment"
"github.com/posit-dev/publisher/internal/events"
"github.com/posit-dev/publisher/internal/logging"
"github.com/posit-dev/publisher/internal/types"
"github.com/posit-dev/publisher/internal/util"
"github.com/posit-dev/publisher/internal/util/utiltest"
"github.com/spf13/afero"
"github.com/stretchr/testify/suite"
)

// Copyright (C) 2024 by Posit Software, PBC.

type GetDeploymentEnvSuite struct {
utiltest.Suite
log logging.Logger
cwd util.AbsolutePath
}

func TestGetDeploymentEnvSuite(t *testing.T) {
suite.Run(t, new(GetDeploymentEnvSuite))
}

func (s *GetDeploymentEnvSuite) SetupSuite() {
s.log = logging.New()
}

func (s *GetDeploymentEnvSuite) SetupTest() {
fs := afero.NewMemMapFs()
cwd, err := util.Getwd(fs)
s.Nil(err)
s.cwd = cwd
s.cwd.MkdirAll(0700)

clientFactory = connect.NewConnectClient
}

func (s *GetDeploymentEnvSuite) TestGetDeploymentEnv() {
path := deployment.GetDeploymentPath(s.cwd, "dep")
d := deployment.New()
d.ID = "123"
d.ServerURL = "https://connect.example.com"
d.WriteFile(path)

lister := &accounts.MockAccountList{}
acct := &accounts.Account{
Name: "myAccount",
URL: "https://connect.example.com",
ServerType: accounts.ServerTypeConnect,
}
lister.On("GetAccountByServerURL", "https://connect.example.com").Return(acct, nil)

client := connect.NewMockClient()
var env types.Environment = []string{"foo", "bar"}
client.On("GetEnvVars", types.ContentID("123"), s.log).Return(&env, nil)
clientFactory = func(account *accounts.Account, timeout time.Duration, emitter events.Emitter, log logging.Logger) (connect.APIClient, error) {
return client, nil
}

h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, lister)

rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/deployments/dep/environment", nil)
s.NoError(err)
req = mux.SetURLVars(req, map[string]string{"name": "dep"})
h(rec, req)

s.Equal(http.StatusOK, rec.Result().StatusCode)
res := types.Environment{}
dec := json.NewDecoder(rec.Body)
dec.DisallowUnknownFields()
s.NoError(dec.Decode(&res))
s.Equal(env, res)
}

func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvDeploymentNotFound() {
h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, &accounts.MockAccountList{})

rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/deployments/nonexistant/environment", nil)
s.NoError(err)
req = mux.SetURLVars(req, map[string]string{"id": "nonexistant"})
h(rec, req)

s.Equal(http.StatusNotFound, rec.Result().StatusCode)
}

func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvFileError() {
path := deployment.GetDeploymentPath(s.cwd, "dep")
err := path.WriteFile([]byte(`foo = 1`), 0666)
s.NoError(err)

h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, &accounts.MockAccountList{})

rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/deployments/dep/environment", nil)
s.NoError(err)
req = mux.SetURLVars(req, map[string]string{"name": "dep"})
h(rec, req)

s.Equal(http.StatusBadRequest, rec.Result().StatusCode)
body, _ := io.ReadAll(rec.Body)
s.Contains(string(body), "deployment dep is invalid")
}

func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvDeploymentNotDeployed() {
path := deployment.GetDeploymentPath(s.cwd, "dep")
d := deployment.New()
d.WriteFile(path)

h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, &accounts.MockAccountList{})

rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/deployments/dep/environment", nil)
s.NoError(err)
req = mux.SetURLVars(req, map[string]string{"name": "dep"})
h(rec, req)

s.Equal(http.StatusBadRequest, rec.Result().StatusCode)
body, _ := io.ReadAll(rec.Body)
s.Contains(string(body), "deployment dep is not deployed")
}

func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvNoCredential() {
path := deployment.GetDeploymentPath(s.cwd, "dep")
d := deployment.New()
d.ID = "123"
d.ServerURL = "https://connect.example.com"
d.WriteFile(path)

lister := &accounts.MockAccountList{}
lister.On("GetAccountByServerURL", "https://connect.example.com").Return(nil, errors.New("no such account"))

h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, lister)

rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/deployments/dep/environment", nil)
s.NoError(err)
req = mux.SetURLVars(req, map[string]string{"name": "dep"})
h(rec, req)

s.Equal(http.StatusBadRequest, rec.Result().StatusCode)
body, _ := io.ReadAll(rec.Body)
s.Contains(string(body), "no credential found to use with deployment dep")
}

func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvPassesStatusFromServer() {
path := deployment.GetDeploymentPath(s.cwd, "dep")
d := deployment.New()
d.ID = "123"
d.ServerURL = "https://connect.example.com"
d.WriteFile(path)

lister := &accounts.MockAccountList{}
acct := &accounts.Account{
Name: "myAccount",
URL: "https://connect.example.com",
ServerType: accounts.ServerTypeConnect,
}
lister.On("GetAccountByServerURL", "https://connect.example.com").Return(acct, nil)

client := connect.NewMockClient()
httpErr := http_client.NewHTTPError("https://connect.example.com", "GET", http.StatusNotFound)
client.On("GetEnvVars", types.ContentID("123"), s.log).Return(nil, httpErr)
clientFactory = func(account *accounts.Account, timeout time.Duration, emitter events.Emitter, log logging.Logger) (connect.APIClient, error) {
return client, nil
}

h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, lister)

rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/deployments/dep/environment", nil)
s.NoError(err)
req = mux.SetURLVars(req, map[string]string{"name": "dep"})
h(rec, req)

s.Equal(http.StatusNotFound, rec.Result().StatusCode)
}
1 change: 1 addition & 0 deletions internal/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Time = time.Time

type ContentName string
type ContentID string
type Environment []string
type BundleID string
type TaskID string
type UserID string
Expand Down

0 comments on commit b525971

Please sign in to comment.