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

Add deployment environment API endpoint #2412

Merged
merged 1 commit into from
Oct 31, 2024
Merged
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
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)).
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure about the name here. Maybe we should be more direct about this being the Connect Content environment?

This is on the deployment resource which implies this is the content, and not the configuration's environment.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense as you have it 👍

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
Comment on lines +38 to +41
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of these error responses can be sent as specific agent errors, like in post_deployment.go. I'm guessing those changes are scoped to a future PR, when bridging the frontend, but wanted to confirm.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is exactly right. I kept it generic for now, and figured we would add agent errors when they were needed by the agent

}

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