diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 222ced7a4..963fff4cc 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -124,6 +124,12 @@ type Interface interface { UpdateHoneycomb(*fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) DeleteHoneycomb(*fastly.DeleteHoneycombInput) error + CreateHeroku(*fastly.CreateHerokuInput) (*fastly.Heroku, error) + ListHerokus(*fastly.ListHerokusInput) ([]*fastly.Heroku, error) + GetHeroku(*fastly.GetHerokuInput) (*fastly.Heroku, error) + UpdateHeroku(*fastly.UpdateHerokuInput) (*fastly.Heroku, error) + DeleteHeroku(*fastly.DeleteHerokuInput) error + GetUser(*fastly.GetUserInput) (*fastly.User, error) GetRegions() (*fastly.RegionsResponse, error) diff --git a/pkg/app/run.go b/pkg/app/run.go index 6408889c7..6919cbd99 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -22,6 +22,7 @@ import ( "github.com/fastly/cli/pkg/logging/bigquery" "github.com/fastly/cli/pkg/logging/ftp" "github.com/fastly/cli/pkg/logging/gcs" + "github.com/fastly/cli/pkg/logging/heroku" "github.com/fastly/cli/pkg/logging/honeycomb" "github.com/fastly/cli/pkg/logging/logentries" "github.com/fastly/cli/pkg/logging/loggly" @@ -215,6 +216,13 @@ func Run(args []string, env config.Environment, file config.File, configFilePath honeycombUpdate := honeycomb.NewUpdateCommand(honeycombRoot.CmdClause, &globals) honeycombDelete := honeycomb.NewDeleteCommand(honeycombRoot.CmdClause, &globals) + herokuRoot := heroku.NewRootCommand(loggingRoot.CmdClause, &globals) + herokuCreate := heroku.NewCreateCommand(herokuRoot.CmdClause, &globals) + herokuList := heroku.NewListCommand(herokuRoot.CmdClause, &globals) + herokuDescribe := heroku.NewDescribeCommand(herokuRoot.CmdClause, &globals) + herokuUpdate := heroku.NewUpdateCommand(herokuRoot.CmdClause, &globals) + herokuDelete := heroku.NewDeleteCommand(herokuRoot.CmdClause, &globals) + statsRoot := stats.NewRootCommand(app, &globals) statsRegions := stats.NewRegionsCommand(statsRoot.CmdClause, &globals) statsHistorical := stats.NewHistoricalCommand(statsRoot.CmdClause, &globals) @@ -355,6 +363,13 @@ func Run(args []string, env config.Environment, file config.File, configFilePath honeycombUpdate, honeycombDelete, + herokuRoot, + herokuCreate, + herokuList, + herokuDescribe, + herokuUpdate, + herokuDelete, + statsRoot, statsRegions, statsHistorical, diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index f2f2c0722..d9aa93638 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -1561,6 +1561,73 @@ COMMANDS --version=VERSION Number of service version -n, --name=NAME The name of the Honeycomb logging object + logging heroku create --name=NAME --version=VERSION --url=URL --auth-token=AUTH-TOKEN [] + Create a Heroku logging endpoint on a Fastly service version + + -n, --name=NAME The name of the Heroku logging object. Used as + a primary key for API access + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + --url=URL The url to stream logs to + --auth-token=AUTH-TOKEN The token to use for authentication + (https://devcenter.heroku.com/articles/add-on-partner-log-integration) + --format=FORMAT Apache style log formatting + --format-version=FORMAT-VERSION + The version of the custom logging format used + for the configured endpoint. Can be either 2 + (default) or 1 + --response-condition=RESPONSE-CONDITION + The name of an existing condition in the + configured endpoint, or leave blank to always + execute + --placement=PLACEMENT Where in the generated VCL the logging call + should be placed, overriding any format_version + default. Can be none or waf_debug + + logging heroku list --version=VERSION [] + List Heroku endpoints on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + + logging heroku describe --version=VERSION --name=NAME [] + Show detailed information about a Heroku logging endpoint on a Fastly + service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -d, --name=NAME The name of the Heroku logging object + + logging heroku update --version=VERSION --name=NAME [] + Update a Heroku logging endpoint on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -n, --name=NAME The name of the Heroku logging object + --new-name=NEW-NAME New name of the Heroku logging object + --format=FORMAT Apache style log formatting + --format-version=FORMAT-VERSION + The version of the custom logging format used + for the configured endpoint. Can be either 2 + (default) or 1 + --url=URL The url to stream logs to + --auth-token=AUTH-TOKEN The token to use for authentication + (https://devcenter.heroku.com/articles/add-on-partner-log-integration) + --response-condition=RESPONSE-CONDITION + The name of an existing condition in the + configured endpoint, or leave blank to always + execute + --placement=PLACEMENT Where in the generated VCL the logging call + should be placed, overriding any format_version + default. Can be none or waf_debug + + logging heroku delete --version=VERSION --name=NAME [] + Delete a Heroku logging endpoint on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + -n, --name=NAME The name of the Heroku logging object + stats regions List stats regions diff --git a/pkg/logging/heroku/create.go b/pkg/logging/heroku/create.go new file mode 100644 index 000000000..e995659b9 --- /dev/null +++ b/pkg/logging/heroku/create.go @@ -0,0 +1,103 @@ +package heroku + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/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/fastly" +) + +// CreateCommand calls the Fastly API to create Heroku logging endpoints. +type CreateCommand struct { + common.Base + manifest manifest.Data + + // required + EndpointName string // Can't shaddow common.Base method Name(). + Version int + Token string + URL string + + // optional + Format common.OptionalString + FormatVersion common.OptionalUint + ResponseCondition common.OptionalString + Placement common.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { + var c CreateCommand + + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("create", "Create a Heroku logging endpoint on a Fastly service version").Alias("add") + + c.CmdClause.Flag("name", "The name of the Heroku logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) + + c.CmdClause.Flag("url", "The url to stream logs to").Required().StringVar(&c.URL) + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://devcenter.heroku.com/articles/add-on-partner-log-integration)").Required().StringVar(&c.Token) + + c.CmdClause.Flag("format", "Apache style log formatting").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. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) + + return &c +} + +// createInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) createInput() (*fastly.CreateHerokuInput, error) { + var input fastly.CreateHerokuInput + + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return nil, errors.ErrNoServiceID + } + + input.Service = serviceID + input.Version = c.Version + input.Name = fastly.String(c.EndpointName) + input.Token = fastly.String(c.Token) + input.URL = fastly.String(c.URL) + + if c.Format.Valid { + input.Format = fastly.String(c.Format.Value) + } + + if c.FormatVersion.Valid { + input.FormatVersion = fastly.Uint(c.FormatVersion.Value) + } + + if c.ResponseCondition.Valid { + input.ResponseCondition = fastly.String(c.ResponseCondition.Value) + } + + if c.Placement.Valid { + input.Placement = fastly.String(c.Placement.Value) + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + input, err := c.createInput() + if err != nil { + return err + } + + d, err := c.Globals.Client.CreateHeroku(input) + if err != nil { + return err + } + + text.Success(out, "Created Heroku logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.Version) + return nil +} diff --git a/pkg/logging/heroku/delete.go b/pkg/logging/heroku/delete.go new file mode 100644 index 000000000..ad456fea3 --- /dev/null +++ b/pkg/logging/heroku/delete.go @@ -0,0 +1,47 @@ +package heroku + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/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/fastly" +) + +// DeleteCommand calls the Fastly API to delete Heroku logging endpoints. +type DeleteCommand struct { + common.Base + manifest manifest.Data + Input fastly.DeleteHerokuInput +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { + var c DeleteCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("delete", "Delete a Heroku logging endpoint on a Fastly service version").Alias("remove") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.Version) + c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.Input.Name) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.Service = serviceID + + if err := c.Globals.Client.DeleteHeroku(&c.Input); err != nil { + return err + } + + text.Success(out, "Deleted Heroku logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.Service, c.Input.Version) + return nil +} diff --git a/pkg/logging/heroku/describe.go b/pkg/logging/heroku/describe.go new file mode 100644 index 000000000..3cd5c8874 --- /dev/null +++ b/pkg/logging/heroku/describe.go @@ -0,0 +1,57 @@ +package heroku + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/go-fastly/fastly" +) + +// DescribeCommand calls the Fastly API to describe a Heroku logging endpoint. +type DescribeCommand struct { + common.Base + manifest manifest.Data + Input fastly.GetHerokuInput +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { + var c DescribeCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("describe", "Show detailed information about a Heroku logging endpoint on a Fastly service version").Alias("get") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.Version) + c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('d').Required().StringVar(&c.Input.Name) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.Service = serviceID + + heroku, err := c.Globals.Client.GetHeroku(&c.Input) + if err != nil { + return err + } + + fmt.Fprintf(out, "Service ID: %s\n", heroku.ServiceID) + fmt.Fprintf(out, "Version: %d\n", heroku.Version) + fmt.Fprintf(out, "Name: %s\n", heroku.Name) + fmt.Fprintf(out, "URL: %s\n", heroku.URL) + fmt.Fprintf(out, "Token: %s\n", heroku.Token) + fmt.Fprintf(out, "Format: %s\n", heroku.Format) + fmt.Fprintf(out, "Format version: %d\n", heroku.FormatVersion) + fmt.Fprintf(out, "Response condition: %s\n", heroku.ResponseCondition) + fmt.Fprintf(out, "Placement: %s\n", heroku.Placement) + + return nil +} diff --git a/pkg/logging/heroku/doc.go b/pkg/logging/heroku/doc.go new file mode 100644 index 000000000..3d4c51b32 --- /dev/null +++ b/pkg/logging/heroku/doc.go @@ -0,0 +1,3 @@ +// Package heroku contains commands to inspect and manipulate Fastly service Heroku +// logging endpoints. +package heroku diff --git a/pkg/logging/heroku/heroku_integration_test.go b/pkg/logging/heroku/heroku_integration_test.go new file mode 100644 index 000000000..c3d66f246 --- /dev/null +++ b/pkg/logging/heroku/heroku_integration_test.go @@ -0,0 +1,395 @@ +package heroku_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/update" + "github.com/fastly/go-fastly/fastly" +) + +func TestHerokuCreate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, + wantError: "error parsing arguments: required flag --auth-token not provided", + }, + { + args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, + wantError: "error parsing arguments: required flag --url not provided", + }, + { + args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc", "--url", "example.com"}, + api: mock.API{CreateHerokuFn: createHerokuOK}, + wantOutput: "Created Heroku logging endpoint log (service 123 version 1)", + }, + { + args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc", "--url", "example.com"}, + api: mock.API{CreateHerokuFn: createHerokuError}, + wantError: errTest.Error(), + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, out.String(), testcase.wantOutput) + }) + } +} + +func TestHerokuList(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListHerokusFn: listHerokusOK}, + wantOutput: listHerokusShortOutput, + }, + { + args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1", "--verbose"}, + api: mock.API{ListHerokusFn: listHerokusOK}, + wantOutput: listHerokusVerboseOutput, + }, + { + args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1", "-v"}, + api: mock.API{ListHerokusFn: listHerokusOK}, + wantOutput: listHerokusVerboseOutput, + }, + { + args: []string{"logging", "heroku", "--verbose", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListHerokusFn: listHerokusOK}, + wantOutput: listHerokusVerboseOutput, + }, + { + args: []string{"logging", "-v", "heroku", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListHerokusFn: listHerokusOK}, + wantOutput: listHerokusVerboseOutput, + }, + { + args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListHerokusFn: listHerokusError}, + wantError: errTest.Error(), + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestHerokuDescribe(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "heroku", "describe", "--service-id", "123", "--version", "1"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"logging", "heroku", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{GetHerokuFn: getHerokuError}, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "heroku", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{GetHerokuFn: getHerokuOK}, + wantOutput: describeHerokuOutput, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, out.String()) + }) + } +} + +func TestHerokuUpdate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, + api: mock.API{ + GetHerokuFn: getHerokuError, + UpdateHerokuFn: updateHerokuOK, + }, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, + api: mock.API{ + GetHerokuFn: getHerokuOK, + UpdateHerokuFn: updateHerokuError, + }, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, + api: mock.API{ + GetHerokuFn: getHerokuOK, + UpdateHerokuFn: updateHerokuOK, + }, + wantOutput: "Updated Heroku logging endpoint log (service 123 version 1)", + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, out.String(), testcase.wantOutput) + }) + } +} + +func TestHerokuDelete(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "heroku", "delete", "--service-id", "123", "--version", "1"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"logging", "heroku", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{DeleteHerokuFn: deleteHerokuError}, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "heroku", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{DeleteHerokuFn: deleteHerokuOK}, + wantOutput: "Deleted Heroku logging endpoint logs (service 123 version 1)", + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + appConfigFile = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, out.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createHerokuOK(i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { + s := fastly.Heroku{ + ServiceID: i.Service, + Version: i.Version, + } + + if i.Name != nil { + s.Name = *i.Name + } + + return &s, nil +} + +func createHerokuError(i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { + return nil, errTest +} + +func listHerokusOK(i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { + return []*fastly.Heroku{ + &fastly.Heroku{ + ServiceID: i.Service, + Version: i.Version, + Name: "logs", + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + URL: "example.com", + Token: "abc", + ResponseCondition: "Prevent default logging", + Placement: "none", + }, + { + ServiceID: i.Service, + Version: i.Version, + Name: "analytics", + URL: "bar.com", + Token: "abc", + Format: `%h %l %u %t "%r" %>s %b`, + ResponseCondition: "Prevent default logging", + FormatVersion: 2, + Placement: "none", + }, + }, nil +} + +func listHerokusError(i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { + return nil, errTest +} + +var listHerokusShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listHerokusVerboseOutput = strings.TrimSpace(` +Fastly API token not provided +Fastly API endpoint: https://api.fastly.com +Service ID: 123 +Version: 1 + Heroku 1/2 + Service ID: 123 + Version: 1 + Name: logs + URL: example.com + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Heroku 2/2 + Service ID: 123 + Version: 1 + Name: analytics + URL: bar.com + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getHerokuOK(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { + return &fastly.Heroku{ + ServiceID: i.Service, + Version: i.Version, + Name: "logs", + URL: "example.com", + Token: "abc", + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + Placement: "none", + }, nil +} + +func getHerokuError(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { + return nil, errTest +} + +var describeHerokuOutput = strings.TrimSpace(` +Service ID: 123 +Version: 1 +Name: logs +URL: example.com +Token: abc +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Response condition: Prevent default logging +Placement: none +`) + "\n" + +func updateHerokuOK(i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { + return &fastly.Heroku{ + ServiceID: i.Service, + Version: i.Version, + Name: "log", + URL: "example.com", + Token: "abc", + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + Placement: "none", + }, nil +} + +func updateHerokuError(i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { + return nil, errTest +} + +func deleteHerokuOK(i *fastly.DeleteHerokuInput) error { + return nil +} + +func deleteHerokuError(i *fastly.DeleteHerokuInput) error { + return errTest +} diff --git a/pkg/logging/heroku/heroku_test.go b/pkg/logging/heroku/heroku_test.go new file mode 100644 index 000000000..1bd4ad118 --- /dev/null +++ b/pkg/logging/heroku/heroku_test.go @@ -0,0 +1,195 @@ +package heroku + +import ( + "testing" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/fastly" +) + +func TestCreateHerokuInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *CreateCommand + want *fastly.CreateHerokuInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateHerokuInput{ + Service: "123", + Version: 2, + Name: fastly.String("log"), + Token: fastly.String("tkn"), + URL: fastly.String("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateHerokuInput{ + Service: "123", + Version: 2, + Name: fastly.String("log"), + Format: fastly.String(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.Uint(2), + Token: fastly.String("tkn"), + URL: fastly.String("example.com"), + ResponseCondition: fastly.String("Prevent default logging"), + Placement: fastly.String("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + have, err := testcase.cmd.createInput() + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + }) + } +} + +func TestUpdateHerokuInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *UpdateCommand + api mock.API + want *fastly.UpdateHerokuInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{GetHerokuFn: getHerokuOK}, + want: &fastly.UpdateHerokuInput{ + Service: "123", + Version: 2, + Name: "logs", + NewName: fastly.String("logs"), + Format: fastly.String(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.Uint(2), + Token: fastly.String("tkn"), + URL: fastly.String("example.com"), + ResponseCondition: fastly.String("Prevent default logging"), + Placement: fastly.String("none"), + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{GetHerokuFn: getHerokuOK}, + want: &fastly.UpdateHerokuInput{ + Service: "123", + Version: 2, + Name: "logs", + NewName: fastly.String("new1"), + Format: fastly.String("new2"), + FormatVersion: fastly.Uint(3), + Token: fastly.String("new3"), + URL: fastly.String("new4"), + ResponseCondition: fastly.String("new5"), + Placement: fastly.String("new6"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Base.Globals.Client = testcase.api + + have, err := testcase.cmd.createInput() + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + }) + } +} + +func createCommandRequired() *CreateCommand { + return &CreateCommand{ + manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, + EndpointName: "log", + Token: "tkn", + URL: "example.com", + Version: 2, + } +} + +func createCommandAll() *CreateCommand { + return &CreateCommand{ + manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, + EndpointName: "log", + Token: "tkn", + URL: "example.com", + Version: 2, + Format: common.OptionalString{Optional: common.Optional{Valid: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 2}, + ResponseCondition: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "Prevent default logging"}, + Placement: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "none"}, + } +} + +func createCommandMissingServiceID() *CreateCommand { + res := createCommandAll() + res.manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *UpdateCommand { + return &UpdateCommand{ + Base: common.Base{Globals: &config.Data{Client: nil}}, + manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, + EndpointName: "log", + Version: 2, + } +} + +func updateCommandAll() *UpdateCommand { + return &UpdateCommand{ + Base: common.Base{Globals: &config.Data{Client: nil}}, + manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, + EndpointName: "log", + Version: 2, + NewName: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new1"}, + Format: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new2"}, + FormatVersion: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 3}, + Token: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new3"}, + URL: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new4"}, + ResponseCondition: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new5"}, + Placement: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new6"}, + } +} + +func updateCommandMissingServiceID() *UpdateCommand { + res := updateCommandAll() + res.manifest = manifest.Data{} + return res +} + +func getHerokuOK(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { + return &fastly.Heroku{ + ServiceID: i.Service, + Version: i.Version, + Name: "logs", + Token: "tkn", + URL: "example.com", + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + Placement: "none", + }, nil +} diff --git a/pkg/logging/heroku/list.go b/pkg/logging/heroku/list.go new file mode 100644 index 000000000..7476e5755 --- /dev/null +++ b/pkg/logging/heroku/list.go @@ -0,0 +1,73 @@ +package heroku + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/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/fastly" +) + +// ListCommand calls the Fastly API to list Heroku logging endpoints. +type ListCommand struct { + common.Base + manifest manifest.Data + Input fastly.ListHerokusInput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { + var c ListCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + c.CmdClause = parent.Command("list", "List Heroku endpoints on a Fastly service version") + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.Version) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.Service = serviceID + + herokus, err := c.Globals.Client.ListHerokus(&c.Input) + if err != nil { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, heroku := range herokus { + tw.AddLine(heroku.ServiceID, heroku.Version, heroku.Name) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Service ID: %s\n", c.Input.Service) + fmt.Fprintf(out, "Version: %d\n", c.Input.Version) + for i, heroku := range herokus { + fmt.Fprintf(out, "\tHeroku %d/%d\n", i+1, len(herokus)) + fmt.Fprintf(out, "\t\tService ID: %s\n", heroku.ServiceID) + fmt.Fprintf(out, "\t\tVersion: %d\n", heroku.Version) + fmt.Fprintf(out, "\t\tName: %s\n", heroku.Name) + fmt.Fprintf(out, "\t\tURL: %s\n", heroku.URL) + fmt.Fprintf(out, "\t\tToken: %s\n", heroku.Token) + fmt.Fprintf(out, "\t\tFormat: %s\n", heroku.Format) + fmt.Fprintf(out, "\t\tFormat version: %d\n", heroku.FormatVersion) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", heroku.ResponseCondition) + fmt.Fprintf(out, "\t\tPlacement: %s\n", heroku.Placement) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/logging/heroku/root.go b/pkg/logging/heroku/root.go new file mode 100644 index 000000000..bac473343 --- /dev/null +++ b/pkg/logging/heroku/root.go @@ -0,0 +1,28 @@ +package heroku + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "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 { + common.Base + // no flags +} + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("heroku", "Manipulate Fastly service version Heroku logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/pkg/logging/heroku/update.go b/pkg/logging/heroku/update.go new file mode 100644 index 000000000..3efa24208 --- /dev/null +++ b/pkg/logging/heroku/update.go @@ -0,0 +1,130 @@ +package heroku + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/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/fastly" +) + +// UpdateCommand calls the Fastly API to update Heroku logging endpoints. +type UpdateCommand struct { + common.Base + manifest manifest.Data + + // required + EndpointName string // Can't shaddow common.Base method Name(). + Version int + + // optional + NewName common.OptionalString + Format common.OptionalString + FormatVersion common.OptionalUint + URL common.OptionalString + Token common.OptionalString + ResponseCondition common.OptionalString + Placement common.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { + var c UpdateCommand + c.Globals = globals + c.manifest.File.Read(manifest.Filename) + + c.CmdClause = parent.Command("update", "Update a Heroku logging endpoint on a Fastly service version") + + c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) + c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) + c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.EndpointName) + + c.CmdClause.Flag("new-name", "New name of the Heroku logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("format", "Apache style log formatting").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. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) + c.CmdClause.Flag("url", "The url to stream logs to").Action(c.URL.Set).StringVar(&c.URL.Value) + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://devcenter.heroku.com/articles/add-on-partner-log-integration)").Action(c.Token.Set).StringVar(&c.Token.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) + + return &c +} + +// createInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) createInput() (*fastly.UpdateHerokuInput, error) { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return nil, errors.ErrNoServiceID + } + + heroku, err := c.Globals.Client.GetHeroku(&fastly.GetHerokuInput{ + Service: serviceID, + Name: c.EndpointName, + Version: c.Version, + }) + if err != nil { + return nil, err + } + + input := fastly.UpdateHerokuInput{ + Service: heroku.ServiceID, + Version: heroku.Version, + Name: heroku.Name, + NewName: fastly.String(heroku.Name), + Format: fastly.String(heroku.Format), + FormatVersion: fastly.Uint(heroku.FormatVersion), + Token: fastly.String(heroku.Token), + URL: fastly.String(heroku.URL), + ResponseCondition: fastly.String(heroku.ResponseCondition), + Placement: fastly.String(heroku.Placement), + } + + if c.NewName.Valid { + input.NewName = fastly.String(c.NewName.Value) + } + + if c.Format.Valid { + input.Format = fastly.String(c.Format.Value) + } + + if c.FormatVersion.Valid { + input.FormatVersion = fastly.Uint(c.FormatVersion.Value) + } + + if c.Token.Valid { + input.Token = fastly.String(c.Token.Value) + } + + if c.URL.Valid { + input.URL = fastly.String(c.URL.Value) + } + + if c.ResponseCondition.Valid { + input.ResponseCondition = fastly.String(c.ResponseCondition.Value) + } + + if c.Placement.Valid { + input.Placement = fastly.String(c.Placement.Value) + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + input, err := c.createInput() + if err != nil { + return err + } + + heroku, err := c.Globals.Client.UpdateHeroku(input) + if err != nil { + return err + } + + text.Success(out, "Updated Heroku logging endpoint %s (service %s version %d)", heroku.Name, heroku.ServiceID, heroku.Version) + return nil +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 73b07827a..ffa536107 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -115,6 +115,12 @@ type API struct { UpdateHoneycombFn func(*fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) DeleteHoneycombFn func(*fastly.DeleteHoneycombInput) error + CreateHerokuFn func(*fastly.CreateHerokuInput) (*fastly.Heroku, error) + ListHerokusFn func(*fastly.ListHerokusInput) ([]*fastly.Heroku, error) + GetHerokuFn func(*fastly.GetHerokuInput) (*fastly.Heroku, error) + UpdateHerokuFn func(*fastly.UpdateHerokuInput) (*fastly.Heroku, error) + DeleteHerokuFn func(*fastly.DeleteHerokuInput) error + GetUserFn func(*fastly.GetUserInput) (*fastly.User, error) GetRegionsFn func() (*fastly.RegionsResponse, error) @@ -566,6 +572,31 @@ func (m API) DeleteHoneycomb(i *fastly.DeleteHoneycombInput) error { return m.DeleteHoneycombFn(i) } +// CreateHeroku implements Interface. +func (m API) CreateHeroku(i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { + return m.CreateHerokuFn(i) +} + +// ListHerokus implements Interface. +func (m API) ListHerokus(i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { + return m.ListHerokusFn(i) +} + +// GetHeroku implements Interface. +func (m API) GetHeroku(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { + return m.GetHerokuFn(i) +} + +// UpdateHeroku implements Interface. +func (m API) UpdateHeroku(i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { + return m.UpdateHerokuFn(i) +} + +// DeleteHeroku implements Interface. +func (m API) DeleteHeroku(i *fastly.DeleteHerokuInput) error { + return m.DeleteHerokuFn(i) +} + // GetUser implements Interface. func (m API) GetUser(i *fastly.GetUserInput) (*fastly.User, error) { return m.GetUserFn(i)