Skip to content

Commit

Permalink
feat(cli): update apply command with new resourcemanager client (#2841)
Browse files Browse the repository at this point in the history
  • Loading branch information
schoren committed Jun 30, 2023
1 parent 2e655ed commit cb7456b
Show file tree
Hide file tree
Showing 44 changed files with 414 additions and 166 deletions.
23 changes: 6 additions & 17 deletions cli/cmd/apply_cmd.go
Expand Up @@ -2,46 +2,35 @@ package cmd

import (
"context"
"fmt"
"strings"

"github.com/kubeshop/tracetest/cli/actions"
"github.com/kubeshop/tracetest/cli/formatters"
"github.com/kubeshop/tracetest/cli/parameters"
"github.com/kubeshop/tracetest/cli/pkg/resourcemanager"
"github.com/spf13/cobra"
)

var applyParams = &parameters.ApplyParams{}

var applyCmd = &cobra.Command{
GroupID: cmdGroupResources.ID,
Use: fmt.Sprintf("apply %s", strings.Join(parameters.ValidResources, "|")),
Use: "apply " + resourceList(),
Short: "Apply resources",
Long: "Apply (create/update) resources to your Tracetest server",
PreRun: setupCommand(),
Run: WithResourceMiddleware(func(_ *cobra.Command, args []string) (string, error) {
resourceType := resourceParams.ResourceName
ctx := context.Background()

resourceActions, err := resourceRegistry.Get(resourceType)

resourceClient, err := resources.Get(resourceType)
if err != nil {
return "", err
}

applyArgs := actions.ApplyArgs{
File: applyParams.DefinitionFile,
}

resource, _, err := resourceActions.Apply(ctx, applyArgs)
resultFormat, err := resourcemanager.Formats.GetWithFallback(output, "yaml")
if err != nil {
return "", err
}

resourceFormatter := resourceActions.Formatter()
formatter := formatters.BuildFormatter(output, formatters.YAML, resourceFormatter)

result, err := formatter.Format(resource)
result, err := resourceClient.Apply(ctx, applyParams.DefinitionFile, resultFormat)
if err != nil {
return "", err
}
Expand All @@ -52,6 +41,6 @@ var applyCmd = &cobra.Command{
}

func init() {
applyCmd.Flags().StringVarP(&applyParams.DefinitionFile, "file", "f", "", "file path with name where to export the resource")
applyCmd.Flags().StringVarP(&applyParams.DefinitionFile, "file", "f", "", "path to the definition file")
rootCmd.AddCommand(applyCmd)
}
3 changes: 0 additions & 3 deletions cli/cmd/config.go
Expand Up @@ -103,7 +103,6 @@ var resources = resourcemanager.NewRegistry().
{Header: "ENABLED", Path: "spec.enabled"},
},
}),
resourcemanager.WithDeleteEnabled("Demo successfully deleted"),
),
).
Register(
Expand Down Expand Up @@ -140,7 +139,6 @@ var resources = resourcemanager.NewRegistry().
{Header: "DESCRIPTION", Path: "spec.description"},
},
}),
resourcemanager.WithDeleteEnabled("Environment successfully deleted"),
),
).
Register(
Expand Down Expand Up @@ -178,7 +176,6 @@ var resources = resourcemanager.NewRegistry().
return nil
},
}),
resourcemanager.WithDeleteEnabled("Transaction successfully deleted"),
),
)

Expand Down
99 changes: 99 additions & 0 deletions cli/pkg/fileutil/file.go
@@ -0,0 +1,99 @@
package fileutil

import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)

type file struct {
path string
contents []byte
}

func Read(filePath string) (file, error) {
b, err := os.ReadFile(filePath)
if err != nil {
return file{}, fmt.Errorf("could not read definition file %s: %w", filePath, err)
}

return New(filePath, b), nil
}

func New(path string, b []byte) file {
file := file{
contents: b,
path: path,
}

return file
}

func (f file) Reader() io.Reader {
return bytes.NewReader(f.contents)
}

var (
hasIDRegex = regexp.MustCompile(`(?m:^\s+id:\s*)`)
indentSizeRegex = regexp.MustCompile(`(?m:^(\s+)\w+)`)
)

var ErrFileHasID = errors.New("file already has ID")

func (f file) HasID() bool {
fileID := hasIDRegex.Find(f.contents)
return fileID != nil
}

func (f file) SetID(id string) (file, error) {
if f.HasID() {
return f, ErrFileHasID
}

indent := indentSizeRegex.FindSubmatchIndex(f.contents)
if len(indent) < 4 {
return f, fmt.Errorf("cannot detect indentation size")
}

indentSize := indent[3] - indent[2]
// indent[2] is the index of the first indentation.
// we can assume that's the first line within the `specs` block
// so we can use it as the place to inejct the ID

var newContents []byte
newContents = append(newContents, f.contents[0:indent[2]]...)

newContents = append(newContents, []byte(strings.Repeat(" ", indentSize))...)
newContents = append(newContents, []byte("id: "+id+"\n")...)

newContents = append(newContents, f.contents[indent[2]:]...)

return New(f.path, newContents), nil
}

func (f file) AbsDir() string {
abs, err := filepath.Abs(f.path)
if err != nil {
panic(fmt.Errorf(`cannot get absolute path from "%s": %w`, f.path, err))
}

return filepath.Dir(abs)
}

func (f file) Write() (file, error) {
err := os.WriteFile(f.path, f.contents, 0644)
if err != nil {
return f, fmt.Errorf("could not write file %s: %w", f.path, err)
}

return Read(f.path)
}

func (f file) ReadAll() (string, error) {
return string(f.contents), nil
}
30 changes: 30 additions & 0 deletions cli/pkg/fileutil/file_test.go
@@ -0,0 +1,30 @@
package fileutil_test

import (
"testing"

"github.com/kubeshop/tracetest/cli/pkg/fileutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSetID(t *testing.T) {
t.Run("NoID", func(t *testing.T) {
f, err := fileutil.Read("../../testdata/definitions/valid_http_test_definition.yml")
require.NoError(t, err)

f, err = f.SetID("new-id")
require.NoError(t, err)

assert.True(t, f.HasID())
})

t.Run("WithID", func(t *testing.T) {
f, err := fileutil.Read("../../testdata/definitions/valid_http_test_definition_with_id.yml")
require.NoError(t, err)

_, err = f.SetID("new-id")
require.ErrorIs(t, err, fileutil.ErrFileHasID)
})

}
99 changes: 99 additions & 0 deletions cli/pkg/resourcemanager/apply.go
@@ -0,0 +1,99 @@
package resourcemanager

import (
"context"
"fmt"
"io"
"net/http"

"github.com/Jeffail/gabs/v2"
"github.com/kubeshop/tracetest/cli/pkg/fileutil"
)

const VerbApply Verb = "apply"

func (c client) Apply(ctx context.Context, filePath string, requestedFormat Format) (string, error) {
inputFile, err := fileutil.Read(filePath)
if err != nil {
return "", fmt.Errorf("cannot read file %s: %w", filePath, err)
}

url := c.client.url(c.resourceNamePlural)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url.String(), inputFile.Reader())
if err != nil {
return "", fmt.Errorf("cannot build Apply request: %w", err)
}

// we want the response inthe user's requested format
err = requestedFormat.BuildRequest(req, VerbApply)
if err != nil {
return "", fmt.Errorf("cannot build Apply request: %w", err)
}

// the files must be in yaml format, so we can safely force the content type,
// even if it doesn't matcht he user's requested format
yamlFormat, err := Formats.Get(FormatYAML)
if err != nil {
return "", fmt.Errorf("cannot get json format: %w", err)
}
req.Header.Set("Content-Type", yamlFormat.ContentType())

// final request looks like this:
// PUT {server}/{resourceNamePlural}
// Content-Type: text/yaml
// Accept: {requestedFormat.contentType}
//
// {yamlFileContent}
//
// This means that we'll send the request body as YAML (read from the user provided file)
// and we'll get the reponse in the users's requrested format.
resp, err := c.client.do(req)
if err != nil {
return "", fmt.Errorf("cannot execute Apply request: %w", err)
}
defer resp.Body.Close()

if !isSuccessResponse(resp) {
err := parseRequestError(resp, requestedFormat)

return "", fmt.Errorf("could not Apply resource: %w", err)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("cannot read Apply response: %w", err)
}

// if the original file doesn't have an ID, we need to get the server generated ID from the response
// and write it to the original file
if !inputFile.HasID() {

jsonBody, err := requestedFormat.ToJSON(body)
if err != nil {
return "", fmt.Errorf("cannot convert response body to JSON format: %w", err)
}

parsed, err := gabs.ParseJSON(jsonBody)
if err != nil {
return "", fmt.Errorf("cannot parse Apply response: %w", err)
}

id, ok := parsed.Path("spec.id").Data().(string)
if !ok {
return "", fmt.Errorf("cannot get ID from Apply response")
}

inputFile, err = inputFile.SetID(id)
if err != nil {
return "", fmt.Errorf("cannot set ID on input file: %w", err)
}

_, err = inputFile.Write()
if err != nil {
return "", fmt.Errorf("cannot write updated input file: %w", err)
}

}

return requestedFormat.Format(string(body), c.tableConfig)
}
17 changes: 12 additions & 5 deletions cli/pkg/resourcemanager/client.go
@@ -1,11 +1,11 @@
package resourcemanager

import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
)

Expand All @@ -28,14 +28,23 @@ type HTTPClient struct {

func NewHTTPClient(baseURL string, extraHeaders http.Header) *HTTPClient {
return &HTTPClient{
client: http.Client{},
client: http.Client{
// this function avoids blindly followin redirects.
// the problem with redirects is that they don't guarantee to preserve the method, body, headers, etc.
// This can hide issues when developing, because the client will follow the redirect and the request
// will succeed, but the server will not receive the request that the user intended to send.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
baseURL: baseURL,
extraHeaders: extraHeaders,
}
}

func (c HTTPClient) url(resourceName string, extra ...string) *url.URL {
url, _ := url.Parse(fmt.Sprintf("%s/api/%s/%s", c.baseURL, resourceName, strings.Join(extra, "/")))
urlStr := c.baseURL + path.Join("/api", resourceName, strings.Join(extra, "/"))
url, _ := url.Parse(urlStr)
return url
}

Expand All @@ -61,8 +70,6 @@ func WithTableConfig(tableConfig TableConfig) options {
}
}

var ErrNotSupportedResourceAction = errors.New("the specified resource type doesn't support the action")

// NewClient creates a new client for a resource managed by the resourceamanger.
// The tableConfig parameter configures how the table view should be rendered.
// This configuration work both for a single resource from a Get, or a ResourceList from a List
Expand Down
15 changes: 10 additions & 5 deletions cli/pkg/resourcemanager/delete.go
Expand Up @@ -4,15 +4,12 @@ import (
"context"
"fmt"
"net/http"
"strings"
)

const VerbDelete Verb = "delete"

func (c client) Delete(ctx context.Context, id string, format Format) (string, error) {
if c.deleteSuccessMsg == "" {
return "", ErrNotSupportedResourceAction
}

url := c.client.url(c.resourceNamePlural, id)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil)
if err != nil {
Expand Down Expand Up @@ -40,5 +37,13 @@ func (c client) Delete(ctx context.Context, id string, format Format) (string, e
return "", fmt.Errorf("could not Delete resource: %w", err)
}

return c.deleteSuccessMsg, nil
msg := ""
if c.deleteSuccessMsg != "" {
msg = c.deleteSuccessMsg
} else {
ucfirst := strings.ToUpper(string(c.resourceName[0])) + c.resourceName[1:]
msg = fmt.Sprintf("%s successfully deleted", ucfirst)
}

return msg, nil
}

0 comments on commit cb7456b

Please sign in to comment.