diff --git a/cli/cmd/apply_cmd.go b/cli/cmd/apply_cmd.go index 9b675223f1..fa139fd66f 100644 --- a/cli/cmd/apply_cmd.go +++ b/cli/cmd/apply_cmd.go @@ -2,12 +2,9 @@ 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" ) @@ -15,7 +12,7 @@ var applyParams = ¶meters.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(), @@ -23,25 +20,17 @@ var applyCmd = &cobra.Command{ 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 } @@ -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) } diff --git a/cli/cmd/config.go b/cli/cmd/config.go index 4905a76740..2f44904861 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -103,7 +103,6 @@ var resources = resourcemanager.NewRegistry(). {Header: "ENABLED", Path: "spec.enabled"}, }, }), - resourcemanager.WithDeleteEnabled("Demo successfully deleted"), ), ). Register( @@ -140,7 +139,6 @@ var resources = resourcemanager.NewRegistry(). {Header: "DESCRIPTION", Path: "spec.description"}, }, }), - resourcemanager.WithDeleteEnabled("Environment successfully deleted"), ), ). Register( @@ -178,7 +176,6 @@ var resources = resourcemanager.NewRegistry(). return nil }, }), - resourcemanager.WithDeleteEnabled("Transaction successfully deleted"), ), ) diff --git a/cli/pkg/fileutil/file.go b/cli/pkg/fileutil/file.go new file mode 100644 index 0000000000..2dbd625000 --- /dev/null +++ b/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 +} diff --git a/cli/pkg/fileutil/file_test.go b/cli/pkg/fileutil/file_test.go new file mode 100644 index 0000000000..567d8c6152 --- /dev/null +++ b/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) + }) + +} diff --git a/cli/pkg/resourcemanager/apply.go b/cli/pkg/resourcemanager/apply.go new file mode 100644 index 0000000000..fc6325f420 --- /dev/null +++ b/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) +} diff --git a/cli/pkg/resourcemanager/client.go b/cli/pkg/resourcemanager/client.go index 49c7fac5c8..52f550c3e9 100644 --- a/cli/pkg/resourcemanager/client.go +++ b/cli/pkg/resourcemanager/client.go @@ -1,11 +1,11 @@ package resourcemanager import ( - "errors" "fmt" "io" "net/http" "net/url" + "path" "strings" ) @@ -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 } @@ -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 diff --git a/cli/pkg/resourcemanager/delete.go b/cli/pkg/resourcemanager/delete.go index e2ab8b63d6..de50d2634a 100644 --- a/cli/pkg/resourcemanager/delete.go +++ b/cli/pkg/resourcemanager/delete.go @@ -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 { @@ -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 } diff --git a/cli/pkg/resourcemanager/format.go b/cli/pkg/resourcemanager/format.go index 9e80954acd..88206d8da9 100644 --- a/cli/pkg/resourcemanager/format.go +++ b/cli/pkg/resourcemanager/format.go @@ -13,8 +13,10 @@ import ( type Format interface { BuildRequest(req *http.Request, verb Verb) error + ContentType() string Format(data string, opts ...any) (string, error) Unmarshal(data []byte, v interface{}) error + ToJSON([]byte) ([]byte, error) String() string } @@ -48,18 +50,24 @@ var Formats = formatRegistry{ prettyFormat{}, } +const FormatJSON = "json" + type jsonFormat struct{} func (j jsonFormat) String() string { - return "json" + return FormatJSON } func (j jsonFormat) BuildRequest(req *http.Request, _ Verb) error { - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", j.ContentType()) req.Header.Set("X-Tracetest-Augmented", "true") return nil } +func (j jsonFormat) ContentType() string { + return "application/json" +} + func (j jsonFormat) Format(data string, _ ...any) (string, error) { indented := bytes.NewBuffer([]byte{}) err := json.Indent(indented, []byte(data), "", " ") @@ -74,20 +82,30 @@ func (j jsonFormat) Unmarshal(data []byte, v interface{}) error { return json.Unmarshal(data, v) } +func (j jsonFormat) ToJSON(in []byte) ([]byte, error) { + return in, nil +} + +const FormatYAML = "yaml" + type yamlFormat struct{} func (y yamlFormat) String() string { - return "yaml" + return FormatYAML } func (y yamlFormat) BuildRequest(req *http.Request, verb Verb) error { - req.Header.Set("Content-Type", "text/yaml") + req.Header.Set("Accept", y.ContentType()) if verb == VerbList { req.Header.Set("Accept", "text/yaml-stream") } return nil } +func (y yamlFormat) ContentType() string { + return "text/yaml" +} + func (y yamlFormat) Format(data string, _ ...any) (string, error) { return data, nil } @@ -96,12 +114,18 @@ func (y yamlFormat) Unmarshal(data []byte, v interface{}) error { return yaml.Unmarshal(data, v) } +func (j yamlFormat) ToJSON(in []byte) ([]byte, error) { + return yaml.YAMLToJSON(in) +} + +const FormatPretty = "pretty" + type prettyFormat struct { jsonFormat } func (p prettyFormat) String() string { - return "pretty" + return FormatPretty } // Format formats data into table using given mappings. diff --git a/run.sh b/run.sh index 872085c859..325ce30d2c 100755 --- a/run.sh +++ b/run.sh @@ -11,7 +11,6 @@ help_message() { } restart() { - build docker compose $opts kill tracetest docker compose $opts up -d tracetest } diff --git a/server/app/app.go b/server/app/app.go index 6d02627287..b14e4ec29d 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -361,7 +361,7 @@ func registerAnalyzerResource(linterRepo *analyzer.Repository, router *mux.Route analyzer.ResourceName, analyzer.ResourceNamePlural, linterRepo, - resourcemanager.WithOperations(analyzer.Operations...), + resourcemanager.DisableDelete(), resourcemanager.WithTracer(tracer), ) manager.RegisterRoutes(router) @@ -385,7 +385,7 @@ func registerConfigResource(configRepo *config.Repository, router *mux.Router, d config.ResourceName, config.ResourceNamePlural, configRepo, - resourcemanager.WithOperations(config.Operations...), + resourcemanager.DisableDelete(), resourcemanager.WithTracer(tracer), ) manager.RegisterRoutes(router) @@ -397,7 +397,7 @@ func registerPollingProfilesResource(repository *pollingprofile.Repository, rout pollingprofile.ResourceName, pollingprofile.ResourceNamePlural, repository, - resourcemanager.WithOperations(pollingprofile.Operations...), + resourcemanager.DisableDelete(), resourcemanager.WithTracer(tracer), ) manager.RegisterRoutes(router) @@ -409,7 +409,6 @@ func registerEnvironmentResource(repository *environment.Repository, router *mux environment.ResourceName, environment.ResourceNamePlural, repository, - resourcemanager.WithOperations(environment.Operations...), resourcemanager.WithTracer(tracer), ) manager.RegisterRoutes(router) @@ -421,7 +420,6 @@ func registerDemosResource(repository *demo.Repository, router *mux.Router, db * demo.ResourceName, demo.ResourceNamePlural, repository, - resourcemanager.WithOperations(demo.Operations...), resourcemanager.WithTracer(tracer), ) manager.RegisterRoutes(router) @@ -433,7 +431,6 @@ func registerDataStoreResource(repository *datastore.Repository, router *mux.Rou datastore.ResourceName, datastore.ResourceNamePlural, repository, - resourcemanager.WithOperations(datastore.Operations...), resourcemanager.WithTracer(tracer), ) manager.RegisterRoutes(router) diff --git a/server/config/config_repository.go b/server/config/config_repository.go index 13bca0e3ba..12cbcb776a 100644 --- a/server/config/config_repository.go +++ b/server/config/config_repository.go @@ -7,15 +7,8 @@ import ( "fmt" "github.com/kubeshop/tracetest/server/pkg/id" - "github.com/kubeshop/tracetest/server/resourcemanager" ) -var Operations = []resourcemanager.Operation{ - resourcemanager.OperationList, - resourcemanager.OperationGet, - resourcemanager.OperationUpdate, -} - type option func(*Repository) func WithPublisher(p publisher) option { @@ -114,6 +107,11 @@ func (*Repository) SortingFields() []string { return []string{"name"} } +func (r *Repository) Create(ctx context.Context, updated Config) (Config, error) { + updated.ID = id.ID("current") + return r.Update(ctx, updated) +} + const ( deleteQuery = "DELETE FROM config" insertQuery = `INSERT INTO config ("analytics_enabled") VALUES ($1)` diff --git a/server/config/config_repository_test.go b/server/config/config_repository_test.go index f8e9b7f9c6..358047d300 100644 --- a/server/config/config_repository_test.go +++ b/server/config/config_repository_test.go @@ -21,7 +21,7 @@ func TestConfigResource(t *testing.T) { config.ResourceName, config.ResourceNamePlural, configRepo, - resourcemanager.WithOperations(config.Operations...), + resourcemanager.DisableDelete(), ) manager.RegisterRoutes(router) diff --git a/server/config/demo/demo_entities.go b/server/config/demo/demo_entities.go index 933178d18d..ddfd7ba463 100644 --- a/server/config/demo/demo_entities.go +++ b/server/config/demo/demo_entities.go @@ -2,7 +2,6 @@ package demo import ( "github.com/kubeshop/tracetest/server/pkg/id" - "github.com/kubeshop/tracetest/server/resourcemanager" ) type DemoType string @@ -17,14 +16,6 @@ const ( ResourceNamePlural = "Demos" ) -var Operations = []resourcemanager.Operation{ - resourcemanager.OperationCreate, - resourcemanager.OperationDelete, - resourcemanager.OperationGet, - resourcemanager.OperationList, - resourcemanager.OperationUpdate, -} - type Demo struct { ID id.ID `json:"id"` Name string `json:"name"` diff --git a/server/config/demo/demo_repository.go b/server/config/demo/demo_repository.go index c165a03f67..0d3acf08d6 100644 --- a/server/config/demo/demo_repository.go +++ b/server/config/demo/demo_repository.go @@ -9,7 +9,6 @@ import ( "github.com/kubeshop/tracetest/server/pkg/id" "github.com/kubeshop/tracetest/server/pkg/sqlutil" - "github.com/kubeshop/tracetest/server/resourcemanager" ) type Repository struct { @@ -20,8 +19,6 @@ func NewRepository(db *sql.DB) *Repository { return &Repository{db} } -var _ resourcemanager.List[Demo] = &Repository{} - func (r *Repository) SetID(demo Demo, id id.ID) Demo { demo.ID = id return demo diff --git a/server/datastore/datastore_repository.go b/server/datastore/datastore_repository.go index 0ebf99bd68..1b1fdaf9da 100644 --- a/server/datastore/datastore_repository.go +++ b/server/datastore/datastore_repository.go @@ -9,16 +9,8 @@ import ( "time" "github.com/kubeshop/tracetest/server/pkg/id" - "github.com/kubeshop/tracetest/server/resourcemanager" ) -var Operations = []resourcemanager.Operation{ - resourcemanager.OperationGet, - resourcemanager.OperationUpdate, - resourcemanager.OperationDelete, - resourcemanager.OperationList, -} - func NewRepository(db *sql.DB) *Repository { return &Repository{db} } @@ -71,6 +63,11 @@ func (r *Repository) getCreatedAt(ctx context.Context, dataStore DataStore) (str return oldDataStore.CreatedAt, nil } +func (r *Repository) Create(ctx context.Context, updated DataStore) (DataStore, error) { + updated.ID = dataStoreSingleID + return r.Update(ctx, updated) +} + func (r *Repository) Update(ctx context.Context, dataStore DataStore) (DataStore, error) { // enforce ID and default dataStore.ID = dataStoreSingleID diff --git a/server/datastore/datastore_repository_test.go b/server/datastore/datastore_repository_test.go index 5ba5e7b1f2..337447217b 100644 --- a/server/datastore/datastore_repository_test.go +++ b/server/datastore/datastore_repository_test.go @@ -44,7 +44,6 @@ func registerManagerFn(router *mux.Router, db *sql.DB) resourcemanager.Manager { datastore.ResourceName, datastore.ResourceNamePlural, dataStoreRepository, - resourcemanager.WithOperations(datastore.Operations...), resourcemanager.WithIDGen(id.GenerateID), ) manager.RegisterRoutes(router) diff --git a/server/environment/environment_repository_test.go b/server/environment/environment_repository_test.go index 5fefaa6720..e84fe7225a 100644 --- a/server/environment/environment_repository_test.go +++ b/server/environment/environment_repository_test.go @@ -58,7 +58,6 @@ func TestEnvironmentRepository(t *testing.T) { environment.ResourceNamePlural, environmentRepository, resourcemanager.WithIDGen(id.GenerateID), - resourcemanager.WithOperations(environment.Operations...), ) manager.RegisterRoutes(router) diff --git a/server/environment/resource.go b/server/environment/resource.go index 8082b6fc12..3ece1b34b8 100644 --- a/server/environment/resource.go +++ b/server/environment/resource.go @@ -1,17 +1,6 @@ package environment -import "github.com/kubeshop/tracetest/server/resourcemanager" - const ( ResourceName = "Environment" ResourceNamePlural = "Environments" ) - -var Operations = []resourcemanager.Operation{ - resourcemanager.OperationCreate, - resourcemanager.OperationUpsert, - resourcemanager.OperationDelete, - resourcemanager.OperationGet, - resourcemanager.OperationList, - resourcemanager.OperationUpdate, -} diff --git a/server/executor/pollingprofile/polling_profile_repository.go b/server/executor/pollingprofile/polling_profile_repository.go index 0b82b61318..5f5d0b63a1 100644 --- a/server/executor/pollingprofile/polling_profile_repository.go +++ b/server/executor/pollingprofile/polling_profile_repository.go @@ -23,6 +23,11 @@ func (r *Repository) SetID(profile PollingProfile, id id.ID) PollingProfile { return profile } +func (r *Repository) Create(ctx context.Context, updated PollingProfile) (PollingProfile, error) { + updated.ID = id.ID("current") + return r.Update(ctx, updated) +} + const ( insertQuery = ` INSERT INTO polling_profiles( diff --git a/server/executor/pollingprofile/polling_profile_repository_test.go b/server/executor/pollingprofile/polling_profile_repository_test.go index 75c5938d1a..c8030103c8 100644 --- a/server/executor/pollingprofile/polling_profile_repository_test.go +++ b/server/executor/pollingprofile/polling_profile_repository_test.go @@ -22,7 +22,7 @@ func TestPollingProfileResource(t *testing.T) { pollingprofile.ResourceName, pollingprofile.ResourceNamePlural, pollingProfileRepo, - resourcemanager.WithOperations(pollingprofile.Operations...), + resourcemanager.DisableDelete(), resourcemanager.WithIDGen(id.GenerateID), ) manager.RegisterRoutes(router) diff --git a/server/linter/analyzer/analyzer_entities.go b/server/linter/analyzer/analyzer_entities.go index 6d6fb6515c..25ce89a97a 100644 --- a/server/linter/analyzer/analyzer_entities.go +++ b/server/linter/analyzer/analyzer_entities.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/kubeshop/tracetest/server/pkg/id" - "github.com/kubeshop/tracetest/server/resourcemanager" ) const ( @@ -12,12 +11,6 @@ const ( ResourceNamePlural = "Analyzers" ) -var Operations = []resourcemanager.Operation{ - resourcemanager.OperationGet, - resourcemanager.OperationList, - resourcemanager.OperationUpdate, -} - type ( Linter struct { ID id.ID `json:"id"` diff --git a/server/linter/analyzer/analyzer_repository.go b/server/linter/analyzer/analyzer_repository.go index fa64c4df6a..7239fd4b27 100644 --- a/server/linter/analyzer/analyzer_repository.go +++ b/server/linter/analyzer/analyzer_repository.go @@ -63,6 +63,11 @@ func (r *Repository) SetID(linters Linter, id id.ID) Linter { return linters } +func (r *Repository) Create(ctx context.Context, linter Linter) (Linter, error) { + linter.ID = id.ID("current") + return r.Update(ctx, linter) +} + func (r *Repository) Update(ctx context.Context, linter Linter) (Linter, error) { // enforce ID and name updated := Linter{ diff --git a/server/linter/analyzer/analyzer_repository_test.go b/server/linter/analyzer/analyzer_repository_test.go index 451fe0c2fd..21cdc945c0 100644 --- a/server/linter/analyzer/analyzer_repository_test.go +++ b/server/linter/analyzer/analyzer_repository_test.go @@ -21,7 +21,7 @@ func TestlinterResource(t *testing.T) { analyzer.ResourceName, analyzer.ResourceNamePlural, repo, - resourcemanager.WithOperations(analyzer.Operations...), + resourcemanager.DisableDelete(), ) manager.RegisterRoutes(router) diff --git a/server/provisioning/provisioning_test.go b/server/provisioning/provisioning_test.go index 59e9ddc285..caf95ff7f0 100644 --- a/server/provisioning/provisioning_test.go +++ b/server/provisioning/provisioning_test.go @@ -172,28 +172,26 @@ func setup(db *sql.DB) provisioningFixture { config.ResourceName, config.ResourceNamePlural, f.configs, - resourcemanager.WithOperations(config.Operations...), + resourcemanager.DisableDelete(), ) pollingProfilesManager := resourcemanager.New[pollingprofile.PollingProfile]( pollingprofile.ResourceName, pollingprofile.ResourceNamePlural, f.pollingProfiles, - resourcemanager.WithOperations(pollingprofile.Operations...), + resourcemanager.DisableDelete(), ) demoManager := resourcemanager.New[demo.Demo]( demo.ResourceName, demo.ResourceNamePlural, f.demos, - resourcemanager.WithOperations(demo.Operations...), ) dataStoreManager := resourcemanager.New[datastore.DataStore]( datastore.ResourceName, datastore.ResourceNamePlural, f.dataStores, - resourcemanager.WithOperations(datastore.Operations...), ) f.provisioner = provisioning.New(provisioning.WithResourceProvisioners( diff --git a/server/resourcemanager/encoding.go b/server/resourcemanager/encoding.go index 9db3264d73..87584463b0 100644 --- a/server/resourcemanager/encoding.go +++ b/server/resourcemanager/encoding.go @@ -190,6 +190,10 @@ func (e Encoder) DecodeRequestBody(out interface{}) (err error) { return fmt.Errorf("cannot read request body: %w", err) } + if len(body) == 0 { + return fmt.Errorf("request body is empty") + } + return e.input.Unmarshal(body, out) } diff --git a/server/resourcemanager/operations.go b/server/resourcemanager/operations.go index 9887dbf31c..e13bccf6fc 100644 --- a/server/resourcemanager/operations.go +++ b/server/resourcemanager/operations.go @@ -14,7 +14,6 @@ const ( OperationNoop Operation = "" OperationList Operation = "list" OperationCreate Operation = "create" - OperationUpsert Operation = "upsert" OperationUpdate Operation = "update" OperationGet Operation = "get" OperationDelete Operation = "delete" diff --git a/server/resourcemanager/resource_manager.go b/server/resourcemanager/resource_manager.go index cd9b0158ed..ac698f03fd 100644 --- a/server/resourcemanager/resource_manager.go +++ b/server/resourcemanager/resource_manager.go @@ -71,6 +71,20 @@ func WithOperations(ops ...Operation) managerOption { } } +func DisableDelete() managerOption { + return func(c *config) { + ops := []Operation{} + for _, op := range availableOperations { + if op == OperationDelete { + continue + } + ops = append(ops, op) + } + + c.enabledOperations = ops + } +} + func WithTracer(tracer trace.Tracer) managerOption { return func(c *config) { c.tracer = tracer @@ -129,29 +143,41 @@ func (m *manager[T]) RegisterRoutes(r *mux.Router) *mux.Router { enabledOps := m.EnabledOperations() + listHandler := m.methodNotAllowed if slices.Contains(enabledOps, OperationList) { - m.instrumentRoute(subrouter.HandleFunc("", m.list).Methods(http.MethodGet).Name(fmt.Sprintf("%s.List", m.resourceTypePlural))) + listHandler = m.list } + m.instrumentRoute(subrouter.HandleFunc("", listHandler).Methods(http.MethodGet).Name(fmt.Sprintf("%s.List", m.resourceTypePlural))) + createHandler := m.methodNotAllowed if slices.Contains(enabledOps, OperationCreate) { - m.instrumentRoute(subrouter.HandleFunc("", m.create).Methods(http.MethodPost).Name(fmt.Sprintf("%s.Create", m.resourceTypePlural))) + createHandler = m.create } + m.instrumentRoute(subrouter.HandleFunc("", createHandler).Methods(http.MethodPost).Name(fmt.Sprintf("%s.Create", m.resourceTypePlural))) - if slices.Contains(enabledOps, OperationUpsert) { - m.instrumentRoute(subrouter.HandleFunc("", m.upsert).Methods(http.MethodPut).Name(fmt.Sprintf("%s.Upsert", m.resourceTypePlural))) + upsertHandler := m.methodNotAllowed + if slices.Contains(enabledOps, OperationCreate) && slices.Contains(enabledOps, OperationUpdate) { + upsertHandler = m.upsert } + m.instrumentRoute(subrouter.HandleFunc("", upsertHandler).Methods(http.MethodPut).Name(fmt.Sprintf("%s.Upsert", m.resourceTypePlural))) + updateHandler := m.methodNotAllowed if slices.Contains(enabledOps, OperationUpdate) { - m.instrumentRoute(subrouter.HandleFunc("/{id}", m.update).Methods(http.MethodPut).Name(fmt.Sprintf("%s.Update", m.resourceTypePlural))) + updateHandler = m.update } + m.instrumentRoute(subrouter.HandleFunc("/{id}", updateHandler).Methods(http.MethodPut).Name(fmt.Sprintf("%s.Update", m.resourceTypePlural))) + getHandler := m.methodNotAllowed if slices.Contains(enabledOps, OperationGet) { - m.instrumentRoute(subrouter.HandleFunc("/{id}", m.get).Methods(http.MethodGet).Name(fmt.Sprintf("%s.Get", m.resourceTypePlural))) + getHandler = m.get } + m.instrumentRoute(subrouter.HandleFunc("/{id}", getHandler).Methods(http.MethodGet).Name(fmt.Sprintf("%s.Get", m.resourceTypePlural))) + deleteHandler := m.methodNotAllowed if slices.Contains(enabledOps, OperationDelete) { - m.instrumentRoute(subrouter.HandleFunc("/{id}", m.delete).Methods(http.MethodDelete).Name(fmt.Sprintf("%s.Delete", m.resourceTypePlural))) + deleteHandler = m.delete } + m.instrumentRoute(subrouter.HandleFunc("/{id}", deleteHandler).Methods(http.MethodDelete).Name(fmt.Sprintf("%s.Delete", m.resourceTypePlural))) return subrouter } @@ -205,6 +231,9 @@ func (m *manager[T]) instrumentRoute(route *mux.Route) { route.Handler(newHandler) } +func (m *manager[T]) methodNotAllowed(w http.ResponseWriter, r *http.Request) { + writeError(w, EncoderFromRequest(r), http.StatusMethodNotAllowed, fmt.Errorf("resource %s does not support the action", m.resourceTypeSingular)) +} func (m *manager[T]) create(w http.ResponseWriter, r *http.Request) { encoder := EncoderFromRequest(r) @@ -218,11 +247,15 @@ func (m *manager[T]) create(w http.ResponseWriter, r *http.Request) { // TODO: if resourceType != values.resourceType return error - if !targetResource.Spec.HasID() { - targetResource.Spec = m.rh.SetID(targetResource.Spec, m.config.idgen()) + m.doCreate(w, r, encoder, targetResource.Spec) +} + +func (m *manager[T]) doCreate(w http.ResponseWriter, r *http.Request, encoder Encoder, specs T) { + if !specs.HasID() { + specs = m.rh.SetID(specs, m.config.idgen()) } - created, err := m.rh.Create(r.Context(), targetResource.Spec) + created, err := m.rh.Create(r.Context(), specs) if err != nil { m.handleResourceHandlerError(w, "creating", err, encoder) return @@ -249,46 +282,27 @@ func (m *manager[T]) upsert(w http.ResponseWriter, r *http.Request) { return } + // if there's no ID given, create the resource if !targetResource.Spec.HasID() { - targetResource.Spec = m.rh.SetID(targetResource.Spec, m.config.idgen()) - } - - writeResponse := func(status int, spec T) { - newResource := Resource[T]{ - Type: m.resourceTypeSingular, - Spec: spec, - } - - err = encoder.WriteEncodedResponse(w, status, newResource) - if err != nil { - writeError(w, encoder, http.StatusInternalServerError, fmt.Errorf("cannot marshal entity: %w", err)) - } + m.doCreate(w, r, encoder, targetResource.Spec) + return } _, err = m.rh.Get(r.Context(), targetResource.Spec.GetID()) if err != nil { - if err == sql.ErrNoRows { - created, err := m.rh.Create(r.Context(), targetResource.Spec) - if err != nil { - writeError(w, encoder, http.StatusInternalServerError, fmt.Errorf("cannot create entity: %w", err)) - return - } - - writeResponse(http.StatusCreated, created) + // if the given ID is not found, create the resource + if errors.Is(err, sql.ErrNoRows) { + m.doCreate(w, r, encoder, targetResource.Spec) return } else { + // some actual error, return it writeError(w, encoder, http.StatusInternalServerError, fmt.Errorf("could not get entity: %w", err)) return } } - updated, err := m.rh.Update(r.Context(), targetResource.Spec) - if err != nil { - writeError(w, encoder, http.StatusInternalServerError, err) - return - } - - writeResponse(http.StatusOK, updated) + // the resurce exists, update it + m.doUpdate(w, r, encoder, targetResource.Spec) } func (m *manager[T]) update(w http.ResponseWriter, r *http.Request) { @@ -312,11 +326,15 @@ func (m *manager[T]) update(w http.ResponseWriter, r *http.Request) { urlID, ) writeError(w, encoder, http.StatusBadRequest, err) + return } - // enforce ID from url in targetResource targetResource.Spec = m.rh.SetID(targetResource.Spec, urlID) - updated, err := m.rh.Update(r.Context(), targetResource.Spec) + m.doUpdate(w, r, encoder, targetResource.Spec) +} + +func (m *manager[T]) doUpdate(w http.ResponseWriter, r *http.Request, encoder Encoder, specs T) { + updated, err := m.rh.Update(r.Context(), specs) if err != nil { m.handleResourceHandlerError(w, "updating", err, encoder) return diff --git a/testing/cli-e2etest/environment/jaeger/resources/data-store.yaml b/testing/cli-e2etest/environment/jaeger/resources/data-store.yaml index e0b3b344bf..c2194af066 100644 --- a/testing/cli-e2etest/environment/jaeger/resources/data-store.yaml +++ b/testing/cli-e2etest/environment/jaeger/resources/data-store.yaml @@ -4,7 +4,6 @@ spec: name: jaeger type: jaeger default: true - createdAt: 2023-05-09T18:22:34.840021Z jaeger: endpoint: jaeger:16685 tls: diff --git a/testing/cli-e2etest/helpers/common.go b/testing/cli-e2etest/helpers/common.go index 6c2c4e1fe2..0c2fba4f5f 100644 --- a/testing/cli-e2etest/helpers/common.go +++ b/testing/cli-e2etest/helpers/common.go @@ -77,3 +77,23 @@ func InjectIdIntoDemoFile(t *testing.T, filePath, id string) { err = os.WriteFile(filePath, newFileContent, os.ModeAppend) require.NoError(t, err) } + +func Copy(source, dst string) { + os.Remove(dst) + sourceFile, err := os.Open(source) + if err != nil { + panic(err) + } + defer sourceFile.Close() + + newFile, err := os.Create(dst) + if err != nil { + panic(err) + } + defer newFile.Close() + + _, err = io.Copy(newFile, sourceFile) + if err != nil { + panic(err) + } +} diff --git a/testing/cli-e2etest/testscenarios/analyzer/delete_analyzer_test.go b/testing/cli-e2etest/testscenarios/analyzer/delete_analyzer_test.go index 90459824b9..2d26a789ab 100644 --- a/testing/cli-e2etest/testscenarios/analyzer/delete_analyzer_test.go +++ b/testing/cli-e2etest/testscenarios/analyzer/delete_analyzer_test.go @@ -26,5 +26,5 @@ func TestDeleteAnalyzer(t *testing.T) { // Then it should return a error message, showing that we cannot delete a analyzer result := tracetestcli.Exec(t, "delete analyzer --id current", tracetestcli.WithCLIConfig(cliConfig)) helpers.RequireExitCodeEqual(t, result, 1) - require.Contains(result.StdErr, "the specified resource type doesn't support the action") + require.Contains(result.StdErr, "resource Analyzer does not support the action") } diff --git a/testing/cli-e2etest/testscenarios/analyzer/list_analyzer_test.go b/testing/cli-e2etest/testscenarios/analyzer/list_analyzer_test.go index 825b857b5c..76a7398584 100644 --- a/testing/cli-e2etest/testscenarios/analyzer/list_analyzer_test.go +++ b/testing/cli-e2etest/testscenarios/analyzer/list_analyzer_test.go @@ -94,7 +94,6 @@ func TestListAnalyzer(t *testing.T) { analyzerList := helpers.UnmarshalJSON[types.ResourceList[types.AnalyzerResource]](t, result.StdOut) require.Len(analyzerList.Items, 1) - require.Equal(len(analyzerList.Items), analyzerList.Count) require.Equal("Analyzer", analyzerList.Items[0].Type) require.Equal("current", analyzerList.Items[0].Spec.Id) diff --git a/testing/cli-e2etest/testscenarios/config/delete_config_test.go b/testing/cli-e2etest/testscenarios/config/delete_config_test.go index ea3b1fa1c1..4a5ac67ded 100644 --- a/testing/cli-e2etest/testscenarios/config/delete_config_test.go +++ b/testing/cli-e2etest/testscenarios/config/delete_config_test.go @@ -26,5 +26,5 @@ func TestDeleteConfig(t *testing.T) { // Then it should return a error message, showing that we cannot delete a config result := tracetestcli.Exec(t, "delete config --id current", tracetestcli.WithCLIConfig(cliConfig)) helpers.RequireExitCodeEqual(t, result, 1) - require.Contains(result.StdErr, "the specified resource type doesn't support the action") + require.Contains(result.StdErr, "resource Config does not support the action") } diff --git a/testing/cli-e2etest/testscenarios/demo/apply_demo_test.go b/testing/cli-e2etest/testscenarios/demo/apply_demo_test.go index 99512b4d58..01c939d22f 100644 --- a/testing/cli-e2etest/testscenarios/demo/apply_demo_test.go +++ b/testing/cli-e2etest/testscenarios/demo/apply_demo_test.go @@ -27,7 +27,6 @@ func TestApplyDemo(t *testing.T) { // When I try to set up a new demo // Then it should be applied with success newDemoPath := env.GetTestResourcePath(t, "new-demo") - helpers.InjectIdIntoDemoFile(t, newDemoPath, "") result := tracetestcli.Exec(t, fmt.Sprintf("apply demo --file %s", newDemoPath), tracetestcli.WithCLIConfig(cliConfig)) helpers.RequireExitCodeEqual(t, result, 0) @@ -63,6 +62,7 @@ func TestApplyDemo(t *testing.T) { // When I try to update the last demo // Then it should be applied with success updatedNewDemoPath := env.GetTestResourcePath(t, "updated-new-demo") + helpers.Copy(updatedNewDemoPath+".tpl", updatedNewDemoPath) helpers.InjectIdIntoDemoFile(t, updatedNewDemoPath, demo.Spec.Id) result = tracetestcli.Exec(t, fmt.Sprintf("apply demo --file %s", updatedNewDemoPath), tracetestcli.WithCLIConfig(cliConfig)) diff --git a/testing/cli-e2etest/testscenarios/demo/resources/.gitignore b/testing/cli-e2etest/testscenarios/demo/resources/.gitignore new file mode 100644 index 0000000000..9489489540 --- /dev/null +++ b/testing/cli-e2etest/testscenarios/demo/resources/.gitignore @@ -0,0 +1 @@ +updated-new-demo.yaml diff --git a/testing/cli-e2etest/testscenarios/demo/resources/another-demo.yaml b/testing/cli-e2etest/testscenarios/demo/resources/another-demo.yaml index d3268f2786..cfff06cede 100644 --- a/testing/cli-e2etest/testscenarios/demo/resources/another-demo.yaml +++ b/testing/cli-e2etest/testscenarios/demo/resources/another-demo.yaml @@ -1,10 +1,14 @@ type: Demo spec: - id: ik_4OWuVg + id: "" name: another-dev - type: pokeshop enabled: true + type: pokeshop + opentelemetryStore: + frontendEndpoint: "" + productCatalogEndpoint: "" + cartEndpoint: "" + checkoutEndpoint: "" pokeshop: httpEndpoint: http://new-dev-endpoint:1234 grpcEndpoint: new-dev-grpc:9091 - opentelemetryStore: {} diff --git a/testing/cli-e2etest/testscenarios/demo/resources/new-demo.yaml b/testing/cli-e2etest/testscenarios/demo/resources/new-demo.yaml index 1fced05456..2b124b2ec1 100644 --- a/testing/cli-e2etest/testscenarios/demo/resources/new-demo.yaml +++ b/testing/cli-e2etest/testscenarios/demo/resources/new-demo.yaml @@ -1,12 +1,14 @@ type: Demo spec: - id: DglVOWu4g + id: "" name: dev - type: otelstore enabled: true - pokeshop: {} + type: otelstore opentelemetryStore: frontendEndpoint: http://dev-frontend:9000 productCatalogEndpoint: http://dev-product:8081 cartEndpoint: http://dev-cart:8082 checkoutEndpoint: http://dev-checkout:8083 + pokeshop: + httpEndpoint: "" + grpcEndpoint: "" diff --git a/testing/cli-e2etest/testscenarios/demo/resources/updated-new-demo.yaml b/testing/cli-e2etest/testscenarios/demo/resources/updated-new-demo.yaml.tpl similarity index 90% rename from testing/cli-e2etest/testscenarios/demo/resources/updated-new-demo.yaml rename to testing/cli-e2etest/testscenarios/demo/resources/updated-new-demo.yaml.tpl index a967911dcb..f22494ac10 100644 --- a/testing/cli-e2etest/testscenarios/demo/resources/updated-new-demo.yaml +++ b/testing/cli-e2etest/testscenarios/demo/resources/updated-new-demo.yaml.tpl @@ -1,10 +1,9 @@ type: Demo spec: - id: DXc4OWXVR + id: "" name: dev-updated - type: otelstore enabled: true - pokeshop: {} + type: otelstore opentelemetryStore: frontendEndpoint: http://dev-updated-frontend:9000 productCatalogEndpoint: http://dev-updated-product:8081 diff --git a/testing/cli-e2etest/testscenarios/environment/resources/another-environment.yaml b/testing/cli-e2etest/testscenarios/environment/resources/another-environment.yaml index c6a1274d78..06cb30c1ad 100644 --- a/testing/cli-e2etest/testscenarios/environment/resources/another-environment.yaml +++ b/testing/cli-e2etest/testscenarios/environment/resources/another-environment.yaml @@ -2,8 +2,6 @@ type: Environment spec: id: another-env name: another-env - description: "" - createdAt: 2023-06-15T22:14:11.945883377Z values: - key: Here value: We diff --git a/testing/cli-e2etest/testscenarios/environment/resources/new-environment.yaml b/testing/cli-e2etest/testscenarios/environment/resources/new-environment.yaml index e24f17bbd5..9d7af611d3 100644 --- a/testing/cli-e2etest/testscenarios/environment/resources/new-environment.yaml +++ b/testing/cli-e2etest/testscenarios/environment/resources/new-environment.yaml @@ -2,8 +2,6 @@ type: Environment spec: id: .env name: .env - description: "" - createdAt: 2023-06-15T22:14:11.917313085Z values: - key: FIRST_VAR value: some-value diff --git a/testing/cli-e2etest/testscenarios/environment/resources/one-more-environment.yaml b/testing/cli-e2etest/testscenarios/environment/resources/one-more-environment.yaml index 863efcb368..122cca0822 100644 --- a/testing/cli-e2etest/testscenarios/environment/resources/one-more-environment.yaml +++ b/testing/cli-e2etest/testscenarios/environment/resources/one-more-environment.yaml @@ -2,8 +2,6 @@ type: Environment spec: id: one-more-env name: one-more-env - description: "" - createdAt: 2023-06-15T22:14:11.971290461Z values: - key: This value: Is diff --git a/testing/cli-e2etest/testscenarios/environment/resources/updated-new-environment.yaml b/testing/cli-e2etest/testscenarios/environment/resources/updated-new-environment.yaml index feb25ba68d..20a7a69c56 100644 --- a/testing/cli-e2etest/testscenarios/environment/resources/updated-new-environment.yaml +++ b/testing/cli-e2etest/testscenarios/environment/resources/updated-new-environment.yaml @@ -2,8 +2,6 @@ type: Environment spec: id: .env name: .env - description: "" - createdAt: 2023-06-15T22:13:58.234562Z values: - key: FIRST_VAR value: some-value diff --git a/testing/cli-e2etest/testscenarios/pollingprofile/delete_pollingprofile_test.go b/testing/cli-e2etest/testscenarios/pollingprofile/delete_pollingprofile_test.go index 2d68ec1798..ec3f103cbb 100644 --- a/testing/cli-e2etest/testscenarios/pollingprofile/delete_pollingprofile_test.go +++ b/testing/cli-e2etest/testscenarios/pollingprofile/delete_pollingprofile_test.go @@ -26,5 +26,5 @@ func TestDeletePollingProfile(t *testing.T) { // Then it should return a error message, showing that we cannot delete a prolling profile result := tracetestcli.Exec(t, "delete pollingprofile --id current", tracetestcli.WithCLIConfig(cliConfig)) helpers.RequireExitCodeEqual(t, result, 1) - require.Contains(result.StdErr, "the specified resource type doesn't support the action") + require.Contains(result.StdErr, "resource PollingProfile does not support the action") } diff --git a/testing/cli-e2etest/testscenarios/test/resources/environment-file.yaml b/testing/cli-e2etest/testscenarios/test/resources/environment-file.yaml index 6ca87a0886..a8267a760a 100644 --- a/testing/cli-e2etest/testscenarios/test/resources/environment-file.yaml +++ b/testing/cli-e2etest/testscenarios/test/resources/environment-file.yaml @@ -2,8 +2,6 @@ type: Environment spec: id: pokeapi-env name: pokeapi-env - description: "" - createdAt: 2023-06-15T22:16:08.965449Z values: - key: POKEMON_NAME value: snorlax diff --git a/testing/server-tracetesting/features/transaction/update_transaction.yml b/testing/server-tracetesting/features/transaction/update_transaction.yml index c066b4453e..5976423e76 100644 --- a/testing/server-tracetesting/features/transaction/update_transaction.yml +++ b/testing/server-tracetesting/features/transaction/update_transaction.yml @@ -37,7 +37,3 @@ spec: assertions: - attr:sql.query contains "INSERT INTO transactions" - outputs: - - name: TRANSACTION_ID - selector: span[name = "Tracetest trigger"] - value: attr:tracetest.response.body | json_path '$.spec.id'