diff --git a/internal/clients/connect/client.go b/internal/clients/connect/client.go index b78b7645c..afeb4e14b 100644 --- a/internal/clients/connect/client.go +++ b/internal/clients/connect/client.go @@ -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) diff --git a/internal/clients/connect/client_connect.go b/internal/clients/connect/client_connect.go index 1bfe629ab..2bbb08979 100644 --- a/internal/clients/connect/client_connect.go +++ b/internal/clients/connect/client_connect.go @@ -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"` diff --git a/internal/clients/connect/mock_client.go b/internal/clients/connect/mock_client.go index afc0fedcc..ef0578bcb 100644 --- a/internal/clients/connect/mock_client.go +++ b/internal/clients/connect/mock_client.go @@ -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) diff --git a/internal/deployment/deployment.go b/internal/deployment/deployment.go index 1ab35cd45..f59b61e78 100644 --- a/internal/deployment/deployment.go +++ b/internal/deployment/deployment.go @@ -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 { diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index d9b5e1ad0..752dd658a 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -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) diff --git a/internal/services/api/get_deployment_env.go b/internal/services/api/get_deployment_env.go new file mode 100644 index 000000000..be6734ffa --- /dev/null +++ b/internal/services/api/get_deployment_env.go @@ -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) + } +} diff --git a/internal/services/api/get_deployment_env_test.go b/internal/services/api/get_deployment_env_test.go new file mode 100644 index 000000000..a34e0e127 --- /dev/null +++ b/internal/services/api/get_deployment_env_test.go @@ -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) +} diff --git a/internal/types/types.go b/internal/types/types.go index 2e168755b..843f8f8bc 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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