diff --git a/CHANGELOG.md b/CHANGELOG.md index b275d1acd..277ecdbd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [v0.35.0](https://github.com/fastly/cli/releases/tag/v0.35.0) (2021-07-29) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.34.0...v0.35.0) + +**Enhancements:** + +- Support for Compute@Edge JS SDK (Beta) [#347](https://gthu.com/fastly/cli/pull/347) +- Implement `--override-host` and `--ssl-sni-hostname` [#352](https://github.com/fastly/cli/pull/352) +- Implement `acl` command [#350](https://github.com/fastly/cli/pull/350) +- Implement `acl-entry` command [#351](https://github.com/fastly/cli/pull/351) +- Separate command files from other logic files [#349](https://github.com/fastly/cli/pull/349) +- Log a record of errors to disk [#340](https://github.com/fastly/cli/pull/340) + +**Bug fixes:** + +- Fix nondeterministic flag parsing [#353](https://github.com/fastly/cli/pull/353) +- Fix `compute serve --addr` description to reference port requirement [#348](https://github.com/fastly/cli/pull/348) + ## [v0.34.0](https://github.com/fastly/cli/releases/tag/v0.34.0) (2021-07-16) [Full Changelog](https://github.com/fastly/cli/compare/v0.33.0...v0.34.0) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 9b93b35b5..3eba459c4 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -262,6 +262,12 @@ type Interface interface { ListACLEntries(i *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) UpdateACLEntry(i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) BatchModifyACLEntries(i *fastly.BatchModifyACLEntriesInput) error + + CreateNewRelic(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) + DeleteNewRelic(i *fastly.DeleteNewRelicInput) error + GetNewRelic(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) + ListNewRelic(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) + UpdateNewRelic(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) } // RealtimeStatsInterface is the subset of go-fastly's realtime stats API used here. diff --git a/pkg/app/run.go b/pkg/app/run.go index bdacdc917..15366a935 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -39,6 +39,7 @@ import ( "github.com/fastly/cli/pkg/commands/logging/logentries" "github.com/fastly/cli/pkg/commands/logging/loggly" "github.com/fastly/cli/pkg/commands/logging/logshuttle" + "github.com/fastly/cli/pkg/commands/logging/newrelic" "github.com/fastly/cli/pkg/commands/logging/openstack" "github.com/fastly/cli/pkg/commands/logging/papertrail" "github.com/fastly/cli/pkg/commands/logging/s3" @@ -299,6 +300,12 @@ func Run(opts RunOpts) error { loggingLogshuttleDescribe := logshuttle.NewDescribeCommand(loggingLogshuttleCmdRoot.CmdClause, &globals) loggingLogshuttleList := logshuttle.NewListCommand(loggingLogshuttleCmdRoot.CmdClause, &globals) loggingLogshuttleUpdate := logshuttle.NewUpdateCommand(loggingLogshuttleCmdRoot.CmdClause, &globals) + loggingNewRelicCmdRoot := newrelic.NewRootCommand(loggingCmdRoot.CmdClause, &globals) + loggingNewRelicCreate := newrelic.NewCreateCommand(loggingNewRelicCmdRoot.CmdClause, &globals) + loggingNewRelicDelete := newrelic.NewDeleteCommand(loggingNewRelicCmdRoot.CmdClause, &globals) + loggingNewRelicDescribe := newrelic.NewDescribeCommand(loggingNewRelicCmdRoot.CmdClause, &globals) + loggingNewRelicList := newrelic.NewListCommand(loggingNewRelicCmdRoot.CmdClause, &globals) + loggingNewRelicUpdate := newrelic.NewUpdateCommand(loggingNewRelicCmdRoot.CmdClause, &globals) loggingOpenstackCmdRoot := openstack.NewRootCommand(loggingCmdRoot.CmdClause, &globals) loggingOpenstackCreate := openstack.NewCreateCommand(loggingOpenstackCmdRoot.CmdClause, &globals) loggingOpenstackDelete := openstack.NewDeleteCommand(loggingOpenstackCmdRoot.CmdClause, &globals) @@ -544,6 +551,12 @@ func Run(opts RunOpts) error { loggingLogshuttleDescribe, loggingLogshuttleList, loggingLogshuttleUpdate, + loggingNewRelicCmdRoot, + loggingNewRelicCreate, + loggingNewRelicDelete, + loggingNewRelicDescribe, + loggingNewRelicList, + loggingNewRelicUpdate, loggingOpenstackCmdRoot, loggingOpenstackCreate, loggingOpenstackDelete, diff --git a/pkg/commands/logging/newrelic/create.go b/pkg/commands/logging/newrelic/create.go new file mode 100644 index 000000000..162bf83cb --- /dev/null +++ b/pkg/commands/logging/newrelic/create.go @@ -0,0 +1,114 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/cli/pkg/cmd" + "github.com/fastly/cli/pkg/commands/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v3/fastly" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent cmd.Registerer, globals *config.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create an New Relic logging endpoint attached to the specified service version").Alias("add") + c.Globals = globals + c.manifest.File.SetOutput(c.Globals.Output) + c.manifest.File.Read(manifest.Filename) + + // Required flags + c.CmdClause.Flag("key", "The Insert API key from the Account page of your New Relic account").Required().StringVar(&c.key) + c.CmdClause.Flag("name", "The name for the real-time logging configuration").Required().StringVar(&c.name) + c.RegisterServiceVersionFlag(cmd.ServiceVersionFlagOpts{ + Dst: &c.serviceVersion.Value, + }) + + // Optional flags + c.RegisterAutoCloneFlag(cmd.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("format", "A Fastly log format string. Must produce valid JSON that New Relic Logs can ingest").StringVar(&c.format) + c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint").UintVar(&c.formatVersion) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed").StringVar(&c.placement) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint").Action(c.responseCondition.Set).StringVar(&c.responseCondition.Value) + c.RegisterServiceIDFlag(&c.manifest.Flag.ServiceID) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + cmd.Base + + autoClone cmd.OptionalAutoClone + format string + formatVersion uint + key string + manifest manifest.Data + name string + placement string + responseCondition cmd.OptionalString + serviceVersion cmd.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := cmd.ServiceDetails(cmd.ServiceDetailsOpts{ + AutoCloneFlag: c.autoClone, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + l, err := c.Globals.Client.CreateNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Created New Relic logging endpoint '%s' (service: %s, version: %d)", l.Name, l.ServiceID, l.ServiceVersion) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.CreateNewRelicInput { + var input fastly.CreateNewRelicInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + input.Token = c.key + + if c.format != "" { + input.Format = c.format + } + if c.formatVersion > 0 { + input.FormatVersion = c.formatVersion + } + if c.placement != "" { + input.Placement = c.placement + } + if c.responseCondition.WasSet { + input.ResponseCondition = c.responseCondition.Value + } + + return &input +} diff --git a/pkg/commands/logging/newrelic/delete.go b/pkg/commands/logging/newrelic/delete.go new file mode 100644 index 000000000..eb635d404 --- /dev/null +++ b/pkg/commands/logging/newrelic/delete.go @@ -0,0 +1,90 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/cli/pkg/cmd" + "github.com/fastly/cli/pkg/commands/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v3/fastly" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent cmd.Registerer, globals *config.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete the New Relic Logs logging object for a particular service and version").Alias("remove") + c.Globals = globals + c.manifest.File.SetOutput(c.Globals.Output) + c.manifest.File.Read(manifest.Filename) + + // Required flags + c.CmdClause.Flag("name", "The name for the real-time logging configuration to delete").Required().StringVar(&c.name) + c.RegisterServiceVersionFlag(cmd.ServiceVersionFlagOpts{ + Dst: &c.serviceVersion.Value, + }) + + // Optional flags + c.RegisterAutoCloneFlag(cmd.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterServiceIDFlag(&c.manifest.Flag.ServiceID) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + cmd.Base + + autoClone cmd.OptionalAutoClone + manifest manifest.Data + name string + serviceVersion cmd.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := cmd.ServiceDetails(cmd.ServiceDetailsOpts{ + AutoCloneFlag: c.autoClone, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + err = c.Globals.Client.DeleteNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Deleted New Relic logging endpoint '%s' (service: %s, version: %d)", c.name, serviceID, serviceVersion.Number) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.DeleteNewRelicInput { + var input fastly.DeleteNewRelicInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/logging/newrelic/describe.go b/pkg/commands/logging/newrelic/describe.go new file mode 100644 index 000000000..a5c2641b5 --- /dev/null +++ b/pkg/commands/logging/newrelic/describe.go @@ -0,0 +1,107 @@ +package newrelic + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/cmd" + "github.com/fastly/cli/pkg/commands/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/go-fastly/v3/fastly" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent cmd.Registerer, globals *config.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Get the details of a New Relic Logs logging object for a particular service and version").Alias("get") + c.Globals = globals + c.manifest.File.SetOutput(c.Globals.Output) + c.manifest.File.Read(manifest.Filename) + + // Required flags + c.CmdClause.Flag("name", "The name for the real-time logging configuration").Required().StringVar(&c.name) + c.RegisterServiceVersionFlag(cmd.ServiceVersionFlagOpts{ + Dst: &c.serviceVersion.Value, + }) + + // Optional Flags + c.RegisterServiceIDFlag(&c.manifest.Flag.ServiceID) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + cmd.Base + + manifest manifest.Data + name string + serviceVersion cmd.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := cmd.ServiceDetails(cmd.ServiceDetailsOpts{ + AllowActiveLocked: true, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + a, err := c.Globals.Client.GetNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + c.print(out, a) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.GetNewRelicInput { + var input fastly.GetNewRelicInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, l *fastly.NewRelic) { + fmt.Fprintf(out, "\nService ID: %s\n", l.ServiceID) + fmt.Fprintf(out, "Service Version: %d\n\n", l.ServiceVersion) + fmt.Fprintf(out, "Name: %s\n", l.Name) + fmt.Fprintf(out, "Token: %s\n", l.Token) + fmt.Fprintf(out, "Format: %s\n", l.Format) + fmt.Fprintf(out, "Format Version: %d\n", l.FormatVersion) + fmt.Fprintf(out, "Placement: %s\n", l.Placement) + fmt.Fprintf(out, "Response Condition: %s\n\n", l.ResponseCondition) + + if l.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", l.CreatedAt) + } + if l.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", l.UpdatedAt) + } + if l.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", l.DeletedAt) + } +} diff --git a/pkg/commands/logging/newrelic/doc.go b/pkg/commands/logging/newrelic/doc.go new file mode 100644 index 000000000..3cf645a3c --- /dev/null +++ b/pkg/commands/logging/newrelic/doc.go @@ -0,0 +1,3 @@ +// Package newrelic contains commands to inspect and manipulate NewRelic logging +// endpoints. +package newrelic diff --git a/pkg/commands/logging/newrelic/list.go b/pkg/commands/logging/newrelic/list.go new file mode 100644 index 000000000..ce257c2fe --- /dev/null +++ b/pkg/commands/logging/newrelic/list.go @@ -0,0 +1,124 @@ +package newrelic + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/cmd" + "github.com/fastly/cli/pkg/commands/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v3/fastly" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent cmd.Registerer, globals *config.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all of the New Relic Logs logging objects for a particular service and version") + c.Globals = globals + c.manifest.File.SetOutput(c.Globals.Output) + c.manifest.File.Read(manifest.Filename) + + // Required flags + c.RegisterServiceVersionFlag(cmd.ServiceVersionFlagOpts{ + Dst: &c.serviceVersion.Value, + }) + + // Optional Flags + c.RegisterServiceIDFlag(&c.manifest.Flag.ServiceID) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + cmd.Base + + manifest manifest.Data + serviceVersion cmd.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := cmd.ServiceDetails(cmd.ServiceDetailsOpts{ + AllowActiveLocked: true, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + l, err := c.Globals.Client.ListNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, serviceID, serviceVersion.Number, l) + } else { + c.printSummary(out, l) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string, serviceVersion int) *fastly.ListNewRelicInput { + var input fastly.ListNewRelicInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceID string, serviceVersion int, ls []*fastly.NewRelic) { + fmt.Fprintf(out, "\nService ID: %s\n", serviceID) + fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) + + for _, l := range ls { + fmt.Fprintf(out, "\nName: %s\n", l.Name) + fmt.Fprintf(out, "\nToken: %s\n", l.Token) + fmt.Fprintf(out, "\nFormat: %s\n", l.Format) + fmt.Fprintf(out, "\nFormat Version: %d\n", l.FormatVersion) + fmt.Fprintf(out, "\nPlacement: %s\n", l.Placement) + fmt.Fprintf(out, "\nResponse Condition: %s\n\n", l.ResponseCondition) + + if l.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", l.CreatedAt) + } + if l.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", l.UpdatedAt) + } + if l.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", l.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, ls []*fastly.NewRelic) { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "VERSION", "NAME") + for _, l := range ls { + t.AddLine(l.ServiceID, l.ServiceVersion, l.Name) + } + t.Print() +} diff --git a/pkg/commands/logging/newrelic/newrelic_test.go b/pkg/commands/logging/newrelic/newrelic_test.go new file mode 100644 index 000000000..f43fe65e3 --- /dev/null +++ b/pkg/commands/logging/newrelic/newrelic_test.go @@ -0,0 +1,420 @@ +package newrelic_test + +import ( + "bytes" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v3/fastly" +) + +func TestNewRelicCreate(t *testing.T) { + args := testutil.Args + scenarios := []testutil.TestScenario{ + { + Name: "validate missing --name flag", + Args: args("logging newrelic create --key abc --version 3"), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --key flag", + Args: args("logging newrelic create --name foo --version 3"), + WantError: "error parsing arguments: required flag --key not provided", + }, + { + Name: "validate missing --version flag", + Args: args("logging newrelic create --key abc --name foo"), + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: args("logging newrelic create --key abc --name foo --version 3"), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: args("logging newrelic create --key abc --name foo --service-id 123 --version 1"), + WantError: "service version 1 is not editable", + }, + { + Name: "validate CreateNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateNewRelicFn: func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: args("logging newrelic create --key abc --name foo --service-id 123 --version 3"), + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateNewRelicFn: func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: i.Name, + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + }, nil + }, + }, + Args: args("logging newrelic create --key abc --name foo --service-id 123 --version 3"), + WantOutput: "Created New Relic logging endpoint 'foo' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateNewRelicFn: func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: i.Name, + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + }, nil + }, + }, + Args: args("logging newrelic create --autoclone --key abc --name foo --service-id 123 --version 1"), + WantOutput: "Created New Relic logging endpoint 'foo' (service: 123, version: 4)", + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.Name, func(t *testing.T) { + var stdout bytes.Buffer + opts := testutil.NewRunOpts(testcase.Args, &stdout) + opts.APIClient = mock.APIClient(testcase.API) + err := app.Run(opts) + testutil.AssertErrorContains(t, err, testcase.WantError) + testutil.AssertStringContains(t, stdout.String(), testcase.WantOutput) + }) + } +} + +func TestNewRelicDelete(t *testing.T) { + args := testutil.Args + scenarios := []testutil.TestScenario{ + { + Name: "validate missing --name flag", + Args: args("logging newrelic delete --version 3"), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: args("logging newrelic delete --name foobar"), + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: args("logging newrelic delete --name foobar --version 3"), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: args("logging newrelic delete --name foobar --service-id 123 --version 1"), + WantError: "service version 1 is not editable", + }, + { + Name: "validate DeleteNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteNewRelicFn: func(i *fastly.DeleteNewRelicInput) error { + return testutil.Err + }, + }, + Args: args("logging newrelic delete --name foobar --service-id 123 --version 3"), + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteNewRelicFn: func(i *fastly.DeleteNewRelicInput) error { + return nil + }, + }, + Args: args("logging newrelic delete --name foobar --service-id 123 --version 3"), + WantOutput: "Deleted New Relic logging endpoint 'foobar' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteNewRelicFn: func(i *fastly.DeleteNewRelicInput) error { + return nil + }, + }, + Args: args("logging newrelic delete --autoclone --name foo --service-id 123 --version 1"), + WantOutput: "Deleted New Relic logging endpoint 'foo' (service: 123, version: 4)", + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.Name, func(t *testing.T) { + var stdout bytes.Buffer + opts := testutil.NewRunOpts(testcase.Args, &stdout) + opts.APIClient = mock.APIClient(testcase.API) + err := app.Run(opts) + testutil.AssertErrorContains(t, err, testcase.WantError) + testutil.AssertStringContains(t, stdout.String(), testcase.WantOutput) + }) + } +} + +func TestNewRelicDescribe(t *testing.T) { + args := testutil.Args + scenarios := []testutil.TestScenario{ + { + Name: "validate missing --name flag", + Args: args("logging newrelic describe --version 3"), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: args("logging newrelic describe --name foobar"), + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: args("logging newrelic describe --name foobar --version 3"), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate GetNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicFn: func(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: args("logging newrelic describe --name foobar --service-id 123 --version 3"), + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicFn: getNewRelic, + }, + Args: args("logging newrelic describe --name foobar --service-id 123 --version 3"), + WantOutput: "\nService ID: 123\nService Version: 3\n\nName: foobar\nToken: abc\nFormat: \nFormat Version: 0\nPlacement: \nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicFn: getNewRelic, + }, + Args: args("logging newrelic describe --name foobar --service-id 123 --version 1"), + WantOutput: "\nService ID: 123\nService Version: 1\n\nName: foobar\nToken: abc\nFormat: \nFormat Version: 0\nPlacement: \nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.Name, func(t *testing.T) { + var stdout bytes.Buffer + opts := testutil.NewRunOpts(testcase.Args, &stdout) + opts.APIClient = mock.APIClient(testcase.API) + err := app.Run(opts) + testutil.AssertErrorContains(t, err, testcase.WantError) + testutil.AssertStringContains(t, stdout.String(), testcase.WantOutput) + }) + } +} + +func TestNewRelicList(t *testing.T) { + args := testutil.Args + scenarios := []testutil.TestScenario{ + { + Name: "validate missing --version flag", + Args: args("logging newrelic list"), + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: args("logging newrelic list --version 3"), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListNewRelics API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: func(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: args("logging newrelic list --service-id 123 --version 3"), + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListNewRelics API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: listNewRelic, + }, + Args: args("logging newrelic list --service-id 123 --version 3"), + WantOutput: "SERVICE ID VERSION NAME\n123 3 foo\n123 3 bar\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: listNewRelic, + }, + Args: args("logging newrelic list --service-id 123 --version 1"), + WantOutput: "SERVICE ID VERSION NAME\n123 1 foo\n123 1 bar\n", + }, + { + Name: "validate missing --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: listNewRelic, + }, + Args: args("logging newrelic list --service-id 123 --verbose --version 1"), + WantOutput: "Fastly API token not provided\nFastly API endpoint: https://api.fastly.com\n\nService ID: 123\nService Version: 1\n\nName: foo\n\nToken: \n\nFormat: \n\nFormat Version: 0\n\nPlacement: \n\nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\nName: bar\n\nToken: \n\nFormat: \n\nFormat Version: 0\n\nPlacement: \n\nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.Name, func(t *testing.T) { + var stdout bytes.Buffer + opts := testutil.NewRunOpts(testcase.Args, &stdout) + opts.APIClient = mock.APIClient(testcase.API) + err := app.Run(opts) + testutil.AssertErrorContains(t, err, testcase.WantError) + testutil.AssertStringContains(t, stdout.String(), testcase.WantOutput) + }) + } +} + +func TestNewRelicUpdate(t *testing.T) { + args := testutil.Args + scenarios := []testutil.TestScenario{ + { + Name: "validate missing --name flag", + Args: args("logging newrelic update --service-id 123 --version 3"), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: args("logging newrelic update --name foobar --service-id 123"), + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: args("logging newrelic update --name foobar --version 3"), + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: args("logging newrelic update --name foobar --service-id 123 --version 1"), + WantError: "service version 1 is not editable", + }, + { + Name: "validate UpdateNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateNewRelicFn: func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: args("logging newrelic update --name foobar --new-name beepboop --service-id 123 --version 3"), + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateNewRelicFn: func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: *i.NewName, + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + }, nil + }, + }, + Args: args("logging newrelic update --name foobar --new-name beepboop --service-id 123 --version 3"), + WantOutput: "Updated New Relic logging endpoint 'beepboop' (previously: foobar, service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateNewRelicFn: func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: *i.NewName, + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + }, nil + }, + }, + Args: args("logging newrelic update --autoclone --name foobar --new-name beepboop --service-id 123 --version 1"), + WantOutput: "Updated New Relic logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.Name, func(t *testing.T) { + var stdout bytes.Buffer + opts := testutil.NewRunOpts(testcase.Args, &stdout) + opts.APIClient = mock.APIClient(testcase.API) + err := app.Run(opts) + testutil.AssertErrorContains(t, err, testcase.WantError) + testutil.AssertStringContains(t, stdout.String(), testcase.WantOutput) + }) + } +} + +func getNewRelic(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { + t := testutil.Date + + return &fastly.NewRelic{ + Name: i.Name, + Token: "abc", + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listNewRelic(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { + t := testutil.Date + vs := []*fastly.NewRelic{ + { + Name: "foo", + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + Name: "bar", + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/pkg/commands/logging/newrelic/root.go b/pkg/commands/logging/newrelic/root.go new file mode 100644 index 000000000..15aa5ea2d --- /dev/null +++ b/pkg/commands/logging/newrelic/root.go @@ -0,0 +1,28 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/cli/pkg/cmd" + "github.com/fastly/cli/pkg/config" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + cmd.Base + // no flags +} + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent cmd.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("newrelic", "Manipulate a NewRelic logging endpoint for a specific Fastly service version") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/newrelic/update.go b/pkg/commands/logging/newrelic/update.go new file mode 100644 index 000000000..58c678b42 --- /dev/null +++ b/pkg/commands/logging/newrelic/update.go @@ -0,0 +1,127 @@ +package newrelic + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/cmd" + "github.com/fastly/cli/pkg/commands/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v3/fastly" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent cmd.Registerer, globals *config.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a New Relic Logs logging object for a particular service and version") + c.Globals = globals + c.manifest.File.SetOutput(c.Globals.Output) + c.manifest.File.Read(manifest.Filename) + + // Required flags + c.CmdClause.Flag("name", "The name for the real-time logging configuration to update").Required().StringVar(&c.name) + c.RegisterServiceVersionFlag(cmd.ServiceVersionFlagOpts{ + Dst: &c.serviceVersion.Value, + }) + + // Optional flags + c.RegisterAutoCloneFlag(cmd.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("format", "A Fastly log format string. Must produce valid JSON that New Relic Logs can ingest").Action(c.format.Set).StringVar(&c.format.Value) + c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint").Action(c.formatVersion.Set).UintVar(&c.formatVersion.Value) + c.CmdClause.Flag("key", "The Insert API key from the Account page of your New Relic account").Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("new-name", "The name for the real-time logging configuration").Action(c.newName.Set).StringVar(&c.newName.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed").Action(c.placement.Set).StringVar(&c.placement.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint").Action(c.responseCondition.Set).StringVar(&c.responseCondition.Value) + c.RegisterServiceIDFlag(&c.manifest.Flag.ServiceID) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + cmd.Base + + autoClone cmd.OptionalAutoClone + format cmd.OptionalString + formatVersion cmd.OptionalUint + key cmd.OptionalString + manifest manifest.Data + name string + newName cmd.OptionalString + placement cmd.OptionalString + responseCondition cmd.OptionalString + serviceVersion cmd.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := cmd.ServiceDetails(cmd.ServiceDetailsOpts{ + AutoCloneFlag: c.autoClone, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + l, err := c.Globals.Client.UpdateNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + var prev string + if c.newName.WasSet { + prev = fmt.Sprintf("previously: %s, ", c.name) + } + + text.Success(out, "Updated New Relic logging endpoint '%s' (%sservice: %s, version: %d)", l.Name, prev, l.ServiceID, l.ServiceVersion) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) *fastly.UpdateNewRelicInput { + var input fastly.UpdateNewRelicInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if c.format.WasSet { + input.Format = fastly.String(c.format.Value) + } + if c.formatVersion.WasSet { + input.FormatVersion = fastly.Uint(c.formatVersion.Value) + } + if c.key.WasSet { + input.Token = fastly.String(c.key.Value) + } + if c.newName.WasSet { + input.NewName = fastly.String(c.newName.Value) + } + if c.placement.WasSet { + input.Placement = fastly.String(c.placement.Value) + } + if c.responseCondition.WasSet { + input.ResponseCondition = fastly.String(c.responseCondition.Value) + } + + return &input +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 3db96aa7c..4c215cf4b 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -253,6 +253,12 @@ type API struct { ListACLEntriesFn func(i *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) UpdateACLEntryFn func(i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) BatchModifyACLEntriesFn func(i *fastly.BatchModifyACLEntriesInput) error + + CreateNewRelicFn func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) + DeleteNewRelicFn func(i *fastly.DeleteNewRelicInput) error + GetNewRelicFn func(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) + ListNewRelicFn func(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) + UpdateNewRelicFn func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) } // AllDatacenters implements Interface. @@ -1269,3 +1275,28 @@ func (m API) UpdateACLEntry(i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, er func (m API) BatchModifyACLEntries(i *fastly.BatchModifyACLEntriesInput) error { return m.BatchModifyACLEntriesFn(i) } + +// CreateNewRelic implements Interface. +func (m API) CreateNewRelic(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return m.CreateNewRelicFn(i) +} + +// DeleteNewRelic implements Interface. +func (m API) DeleteNewRelic(i *fastly.DeleteNewRelicInput) error { + return m.DeleteNewRelicFn(i) +} + +// GetNewRelic implements Interface. +func (m API) GetNewRelic(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { + return m.GetNewRelicFn(i) +} + +// ListNewRelic implements Interface. +func (m API) ListNewRelic(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { + return m.ListNewRelicFn(i) +} + +// UpdateNewRelic implements Interface. +func (m API) UpdateNewRelic(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return m.UpdateNewRelicFn(i) +}