Skip to content

Commit

Permalink
Dlc purge (#869)
Browse files Browse the repository at this point in the history
* Delete unused code

These fields are never read from, so we can delete them.

* Delete code that doesn't do anything

This code has no effect (the endpoint variable is never used, nor the
value being written back to the struct it came from).

* Allow creating rest clients directly not from conf

Split the New into two different functions, one that takes exactly what
is needed to create the struct, and another that is able to assemble
these values from the config.

* Create a client for speaking to dl.circleci.com

- Also, add code for calling the DLC purge endpoint

* Add the command to purge DLC

---------

Co-authored-by: Abraham Tewa <[email protected]>
Co-authored-by: JulesFaucherre <[email protected]>
  • Loading branch information
3 people authored Mar 21, 2023
1 parent 199d46b commit 1e8b8c7
Show file tree
Hide file tree
Showing 16 changed files with 481 additions and 32 deletions.
6 changes: 6 additions & 0 deletions api/dl/dl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dl

// ProjectClient is the interface to interact with dl
type DlClient interface {
PurgeDLC(projectid string) error
}
80 changes: 80 additions & 0 deletions api/dl/dl_rest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package dl

import (
"fmt"
"net/url"
"time"

"github.com/CircleCI-Public/circleci-cli/api/rest"
"github.com/CircleCI-Public/circleci-cli/settings"
)

const defaultDlHost = "https://dl.circleci.com"

type dlRestClient struct {
client *rest.Client
}

// NewDlRestClient returns a new dlRestClient instance initialized with the
// values from the config.
func NewDlRestClient(config settings.Config) (*dlRestClient, error) { //
// We don't want the user to use this with Server as that's nor supported
// at them moment. In order to detect this we look if there's a config file
// or cli option that sets "host" to anything different than the default
if config.Host != "" && config.Host != "https://circleci.com" {
// Only error if there's no custom DlHost set. Since the end user can't
// a custom value set this in the config file, this has to have been
// manually been set in the code, presumably by the test suite to allow
// talking to a mock server, and we want to allow that.
if config.DlHost == "" {
return nil, &CloudOnlyErr{}
}
}

// what's the base URL?
unparsedURL := defaultDlHost
if config.DlHost != "" {
unparsedURL = config.DlHost
}

baseURL, err := url.Parse(unparsedURL)
if err != nil {
return nil, fmt.Errorf("cannot parse dl host URL '%s'", unparsedURL)
}

httpclient := config.HTTPClient
httpclient.Timeout = 10 * time.Second

// the dl endpoint is hardcoded to https://dl.circleci.com, since currently
// this implementation always refers to the cloud dl service
return &dlRestClient{
client: rest.New(
baseURL,
config.Token,
httpclient,
),
}, nil
}

func (c dlRestClient) PurgeDLC(projectid string) error {
// this calls a private circleci endpoint. We make no guarantees about
// this still existing in the future.
path := fmt.Sprintf("private/output/project/%s/dlc", projectid)
req, err := c.client.NewRequest("DELETE", &url.URL{Path: path}, nil)
if err != nil {
return err
}

status, err := c.client.DoRequest(req, nil)

// Futureproofing: If CircleCI ever removes the private backend endpoint
// this call uses, by having the endpoint return a 410 status code CircleCI
// can get everyone running an outdated client to display a helpful error
// telling them to upgrade (presumably by this point a version without this
// logic will have been released)
if status == 410 {
return &GoneErr{}
}

return err
}
81 changes: 81 additions & 0 deletions api/dl/dl_rest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dl

import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"

"gotest.tools/v3/assert"

"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
)

// getDlRestClient returns a dlRestClient hooked up to the passed server
func getDlRestClient(server *httptest.Server) (*dlRestClient, error) {
return NewDlRestClient(settings.Config{
DlHost: server.URL,
HTTPClient: http.DefaultClient,
Token: "token",
})
}

func Test_DLCPurge(t *testing.T) {
tests := []struct {
name string
handler http.HandlerFunc
wantErr bool
}{
{
name: "Should handle a successful request",
handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Header.Get("circle-token"), "token")
assert.Equal(t, r.Header.Get("accept"), "application/json")
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())

assert.Equal(t, r.Method, "DELETE")
assert.Equal(t, r.URL.Path, fmt.Sprintf("/private/output/project/%s/dlc", "projectid"))

// check the request was made with an empty body
br := r.Body
b, err := io.ReadAll(br)
assert.NilError(t, err)
assert.Equal(t, string(b), "")
assert.NilError(t, br.Close())

// send response as empty 200
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(``))
assert.NilError(t, err)
},
},
{
name: "Should handle an error request",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte(`{"message": "error"}`))
assert.NilError(t, err)
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()

c, err := getDlRestClient(server)
assert.NilError(t, err)

err = c.PurgeDLC("projectid")
if (err != nil) != tt.wantErr {
t.Errorf("PurgeDLC() error = %#v (%s), wantErr %v", err, err, tt.wantErr)
return
}
})
}
}
27 changes: 27 additions & 0 deletions api/dl/err.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dl

type CloudOnlyErr struct{}

func (e *CloudOnlyErr) Error() string {
return "Misconfiguration.\n" +
"You have configured a custom API endpoint host for the circleci CLI.\n" +
"However, this functionality is only supported on circleci.com API endpoints."
}

func IsCloudOnlyErr(err error) bool {
_, ok := err.(*CloudOnlyErr)
return ok
}

type GoneErr struct{}

func (e *GoneErr) Error() string {
return "No longer supported.\n" +
"This functionality is no longer supported by this version of the circleci CLI.\n" +
"Please upgrade to the latest version of the circleci CLI."
}

func IsGoneErr(err error) bool {
_, ok := err.(*GoneErr)
return ok
}
6 changes: 6 additions & 0 deletions api/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ type ProjectEnvironmentVariable struct {
Value string
}

// ProjectInfo is the info of a Project
type ProjectInfo struct {
Id string
}

// ProjectClient is the interface to interact with project and it's
// components.
type ProjectClient interface {
ProjectInfo(vcs, org, project string) (*ProjectInfo, error)
ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error)
GetEnvironmentVariable(vcs, org, project, envName string) (*ProjectEnvironmentVariable, error)
CreateEnvironmentVariable(vcs, org, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error)
Expand Down
36 changes: 25 additions & 11 deletions api/project/project_rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
)

type projectRestClient struct {
token string
server string
client *rest.Client
}

Expand Down Expand Up @@ -38,20 +36,18 @@ type createProjectEnvVarRequest struct {
Value string `json:"value"`
}

// projectInfo is the info returned by "Get a project" API endpoint.
// This struct does not contain all the fields returned by the API.
type projectInfo struct {
Id string `json:"id"`
}

// NewProjectRestClient returns a new projectRestClient satisfying the api.ProjectInterface
// interface via the REST API.
func NewProjectRestClient(config settings.Config) (*projectRestClient, error) {
serverURL, err := config.ServerURL()
if err != nil {
return nil, err
}

client := &projectRestClient{
token: config.Token,
server: serverURL.String(),
client: rest.New(config.Host, &config),
client: rest.NewFromConfig(config.Host, &config),
}

return client, nil
}

Expand Down Expand Up @@ -155,3 +151,21 @@ func (c *projectRestClient) CreateEnvironmentVariable(vcs string, org string, pr
Value: resp.Value,
}, nil
}

// ProjectInfo retrieves and returns the project info.
func (c *projectRestClient) ProjectInfo(vcs string, org string, project string) (*ProjectInfo, error) {
path := fmt.Sprintf("project/%s/%s/%s", vcs, org, project)
req, err := c.client.NewRequest("GET", &url.URL{Path: path}, nil)
if err != nil {
return nil, err
}

var resp projectInfo
_, err = c.client.DoRequest(req, &resp)
if err != nil {
return nil, err
}
return &ProjectInfo{
Id: resp.Id,
}, nil
}
74 changes: 73 additions & 1 deletion api/project/project_rest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ package project_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"

"gotest.tools/v3/assert"

"github.com/CircleCI-Public/circleci-cli/api/project"
"github.com/CircleCI-Public/circleci-cli/settings"
"github.com/CircleCI-Public/circleci-cli/version"
"gotest.tools/v3/assert"
)

func getProjectRestClient(server *httptest.Server) (project.ProjectClient, error) {
Expand Down Expand Up @@ -321,3 +323,73 @@ func Test_projectRestClient_CreateEnvironmentVariable(t *testing.T) {
})
}
}

func Test_projectRestClient_ProjectInfo(t *testing.T) {
const (
vcsType = "github"
orgName = "test-org"
projName = "test-proj"
)
tests := []struct {
name string
handler http.HandlerFunc
want *project.ProjectInfo
wantErr bool
}{
{
name: "Should handle a successful request",
handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Header.Get("circle-token"), "token")
assert.Equal(t, r.Header.Get("accept"), "application/json")
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())

assert.Equal(t, r.Method, "GET")
assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s", vcsType, orgName, projName))
br := r.Body
b, err := io.ReadAll(br)
assert.NilError(t, err)
assert.Equal(t, string(b), "")
assert.NilError(t, br.Close())

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(`
{
"id": "this-is-the-id"
}`))
assert.NilError(t, err)
},
want: &project.ProjectInfo{
Id: "this-is-the-id",
},
},
{
name: "Should handle an error request",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte(`{"message": "error"}`))
assert.NilError(t, err)
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(tt.handler)
defer server.Close()

p, err := getProjectRestClient(server)
assert.NilError(t, err)

got, err := p.ProjectInfo(vcsType, orgName, projName)
if (err != nil) != tt.wantErr {
t.Errorf("projectRestClient.ProjectInfo() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("projectRestClient.ProjectInfo() = %v, want %v", got, tt.want)
}
})
}
}
Loading

0 comments on commit 1e8b8c7

Please sign in to comment.