diff --git a/api/beta.yaml b/api/beta.yaml index 79f3dc8f0..cba870c1e 100644 --- a/api/beta.yaml +++ b/api/beta.yaml @@ -817,6 +817,31 @@ paths: security: - bearer: [] /v1/projects/{ref}: + get: + operationId: v1-get-project + summary: Gets a specific project that belongs to the authenticated user + parameters: + - name: ref + required: true + in: path + description: Project ref + schema: + minLength: 20 + maxLength: 20 + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/V1ProjectResponse' + '500': + description: Failed to retrieve project + tags: + - Projects + security: + - bearer: [] delete: operationId: v1-delete-a-project summary: Deletes the given project diff --git a/internal/link/link.go b/internal/link/link.go index 1d3824b84..0c2aa1de4 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -29,6 +29,11 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func( "api": utils.Config.Api, "db": utils.Config.Db, }) + + if err := checkRemoteProjectStatus(ctx, projectRef); err != nil { + return err + } + // 1. Check service config keys, err := tenant.GetApiKeys(ctx, projectRef) if err != nil { @@ -236,3 +241,26 @@ func updatePoolerConfig(config api.SupavisorConfigResponse) { utils.Config.Db.Pooler.MaxClientConn = uint(*config.MaxClientConn) } } + +func checkRemoteProjectStatus(ctx context.Context, projectRef string) error { + resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, projectRef) + if err != nil { + return errors.Errorf("failed to retrieve remote project status: %w", err) + } + if resp.JSON200 == nil { + return errors.New("Unexpected error retrieving remote project status: " + string(resp.Body)) + } + + switch resp.JSON200.Status { + case api.V1ProjectResponseStatusINACTIVE: + utils.CmdSuggestion = fmt.Sprintf("An admin must unpause it from the Supabase dashboard at %s", utils.Aqua(fmt.Sprintf("%s/project/%s", utils.GetSupabaseDashboardURL(), projectRef))) + return errors.New("project is paused") + case api.V1ProjectResponseStatusACTIVEHEALTHY: + // Project is in the desired state, do nothing + return nil + default: + fmt.Fprintf(os.Stderr, "%s: Project status is %s instead of Active Healthy. Some operations might fail.\n", utils.Yellow("Warning"), resp.JSON200.Status) + } + + return nil +} diff --git a/internal/link/link_test.go b/internal/link/link_test.go index edc95d8fa..f39388bfd 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -49,6 +49,11 @@ func TestLinkCommand(t *testing.T) { helper.MockSeedHistory(conn) // Flush pending mocks after test execution defer gock.OffAll() + // Mock project status + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + project). + Reply(200). + JSON(api.V1ProjectResponse{Status: api.V1ProjectResponseStatusACTIVEHEALTHY}) gock.New(utils.DefaultApiHost). Get("/v1/projects/" + project + "/api-keys"). Reply(200). @@ -120,6 +125,11 @@ func TestLinkCommand(t *testing.T) { fsys := afero.NewMemMapFs() // Flush pending mocks after test execution defer gock.OffAll() + // Mock project status + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + project). + Reply(200). + JSON(api.V1ProjectResponse{Status: api.V1ProjectResponseStatusACTIVEHEALTHY}) gock.New(utils.DefaultApiHost). Get("/v1/projects/" + project + "/api-keys"). Reply(200). @@ -160,6 +170,11 @@ func TestLinkCommand(t *testing.T) { fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) // Flush pending mocks after test execution defer gock.OffAll() + // Mock project status + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + project). + Reply(200). + JSON(api.V1ProjectResponse{Status: api.V1ProjectResponseStatusACTIVEHEALTHY}) gock.New(utils.DefaultApiHost). Get("/v1/projects/" + project + "/api-keys"). Reply(200). @@ -194,6 +209,22 @@ func TestLinkCommand(t *testing.T) { assert.NoError(t, err) assert.False(t, exists) }) + t.Run("throws error on project inactive", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) + // Flush pending mocks after test execution + defer gock.OffAll() + // Mock project status + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + project). + Reply(200). + JSON(api.V1ProjectResponse{Status: api.V1ProjectResponseStatusINACTIVE}) + // Run test + err := Run(context.Background(), project, fsys) + // Check error + assert.ErrorContains(t, err, "project is paused") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) } func TestLinkPostgrest(t *testing.T) { diff --git a/pkg/api/client.gen.go b/pkg/api/client.gen.go index 594246049..151b764e7 100644 --- a/pkg/api/client.gen.go +++ b/pkg/api/client.gen.go @@ -136,6 +136,9 @@ type ClientInterface interface { // V1DeleteAProject request V1DeleteAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1GetProject request + V1GetProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1GetProjectApiKeys request V1GetProjectApiKeys(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -581,6 +584,18 @@ func (c *Client) V1DeleteAProject(ctx context.Context, ref string, reqEditors .. return c.Client.Do(req) } +func (c *Client) V1GetProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1GetProjectRequest(c.Server, ref) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) V1GetProjectApiKeys(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewV1GetProjectApiKeysRequest(c.Server, ref) if err != nil { @@ -2211,6 +2226,40 @@ func NewV1DeleteAProjectRequest(server string, ref string) (*http.Request, error return req, nil } +// NewV1GetProjectRequest generates requests for V1GetProject +func NewV1GetProjectRequest(server string, ref string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/projects/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewV1GetProjectApiKeysRequest generates requests for V1GetProjectApiKeys func NewV1GetProjectApiKeysRequest(server string, ref string) (*http.Request, error) { var err error @@ -5127,6 +5176,9 @@ type ClientWithResponsesInterface interface { // V1DeleteAProjectWithResponse request V1DeleteAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DeleteAProjectResponse, error) + // V1GetProjectWithResponse request + V1GetProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectResponse, error) + // V1GetProjectApiKeysWithResponse request V1GetProjectApiKeysWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectApiKeysResponse, error) @@ -5653,6 +5705,28 @@ func (r V1DeleteAProjectResponse) StatusCode() int { return 0 } +type V1GetProjectResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1ProjectResponse +} + +// Status returns HTTPResponse.Status +func (r V1GetProjectResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1GetProjectResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type V1GetProjectApiKeysResponse struct { Body []byte HTTPResponse *http.Response @@ -7198,6 +7272,15 @@ func (c *ClientWithResponses) V1DeleteAProjectWithResponse(ctx context.Context, return ParseV1DeleteAProjectResponse(rsp) } +// V1GetProjectWithResponse request returning *V1GetProjectResponse +func (c *ClientWithResponses) V1GetProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectResponse, error) { + rsp, err := c.V1GetProject(ctx, ref, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1GetProjectResponse(rsp) +} + // V1GetProjectApiKeysWithResponse request returning *V1GetProjectApiKeysResponse func (c *ClientWithResponses) V1GetProjectApiKeysWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1GetProjectApiKeysResponse, error) { rsp, err := c.V1GetProjectApiKeys(ctx, ref, reqEditors...) @@ -8294,6 +8377,32 @@ func ParseV1DeleteAProjectResponse(rsp *http.Response) (*V1DeleteAProjectRespons return response, nil } +// ParseV1GetProjectResponse parses an HTTP response from a V1GetProjectWithResponse call +func ParseV1GetProjectResponse(rsp *http.Response) (*V1GetProjectResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1GetProjectResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1ProjectResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseV1GetProjectApiKeysResponse parses an HTTP response from a V1GetProjectApiKeysWithResponse call func ParseV1GetProjectApiKeysResponse(rsp *http.Response) (*V1GetProjectApiKeysResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body)