From 264c782b4f94a082e5f7f5acfc3135809bcd3a05 Mon Sep 17 00:00:00 2001 From: Colton McCurdy Date: Thu, 14 May 2020 15:50:53 -0400 Subject: [PATCH] logging: adds SFTP logging endpoint support Signed-off-by: Colton McCurdy --- pkg/api/interface.go | 6 + pkg/app/run.go | 15 + pkg/app/run_test.go | 113 +++++ pkg/logging/sftp/create.go | 154 +++++++ pkg/logging/sftp/delete.go | 47 ++ pkg/logging/sftp/describe.go | 66 +++ pkg/logging/sftp/doc.go | 3 + pkg/logging/sftp/list.go | 82 ++++ pkg/logging/sftp/root.go | 28 ++ pkg/logging/sftp/sftp_integration_test.go | 523 ++++++++++++++++++++++ pkg/logging/sftp/sftp_test.go | 313 +++++++++++++ pkg/logging/sftp/update.go | 193 ++++++++ pkg/mock/api.go | 31 ++ 13 files changed, 1574 insertions(+) create mode 100644 pkg/logging/sftp/create.go create mode 100644 pkg/logging/sftp/delete.go create mode 100644 pkg/logging/sftp/describe.go create mode 100644 pkg/logging/sftp/doc.go create mode 100644 pkg/logging/sftp/list.go create mode 100644 pkg/logging/sftp/root.go create mode 100644 pkg/logging/sftp/sftp_integration_test.go create mode 100644 pkg/logging/sftp/sftp_test.go create mode 100644 pkg/logging/sftp/update.go diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 963fff4cc..0fd49656d 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -130,6 +130,12 @@ type Interface interface { UpdateHeroku(*fastly.UpdateHerokuInput) (*fastly.Heroku, error) DeleteHeroku(*fastly.DeleteHerokuInput) error + CreateSFTP(*fastly.CreateSFTPInput) (*fastly.SFTP, error) + ListSFTPs(*fastly.ListSFTPsInput) ([]*fastly.SFTP, error) + GetSFTP(*fastly.GetSFTPInput) (*fastly.SFTP, error) + UpdateSFTP(*fastly.UpdateSFTPInput) (*fastly.SFTP, error) + DeleteSFTP(*fastly.DeleteSFTPInput) error + GetUser(*fastly.GetUserInput) (*fastly.User, error) GetRegions() (*fastly.RegionsResponse, error) diff --git a/pkg/app/run.go b/pkg/app/run.go index 6919cbd99..e71ba89b2 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -29,6 +29,7 @@ import ( "github.com/fastly/cli/pkg/logging/papertrail" "github.com/fastly/cli/pkg/logging/s3" "github.com/fastly/cli/pkg/logging/scalyr" + "github.com/fastly/cli/pkg/logging/sftp" "github.com/fastly/cli/pkg/logging/splunk" "github.com/fastly/cli/pkg/logging/sumologic" "github.com/fastly/cli/pkg/logging/syslog" @@ -223,6 +224,13 @@ func Run(args []string, env config.Environment, file config.File, configFilePath herokuUpdate := heroku.NewUpdateCommand(herokuRoot.CmdClause, &globals) herokuDelete := heroku.NewDeleteCommand(herokuRoot.CmdClause, &globals) + sftpRoot := sftp.NewRootCommand(loggingRoot.CmdClause, &globals) + sftpCreate := sftp.NewCreateCommand(sftpRoot.CmdClause, &globals) + sftpList := sftp.NewListCommand(sftpRoot.CmdClause, &globals) + sftpDescribe := sftp.NewDescribeCommand(sftpRoot.CmdClause, &globals) + sftpUpdate := sftp.NewUpdateCommand(sftpRoot.CmdClause, &globals) + sftpDelete := sftp.NewDeleteCommand(sftpRoot.CmdClause, &globals) + statsRoot := stats.NewRootCommand(app, &globals) statsRegions := stats.NewRegionsCommand(statsRoot.CmdClause, &globals) statsHistorical := stats.NewHistoricalCommand(statsRoot.CmdClause, &globals) @@ -370,6 +378,13 @@ func Run(args []string, env config.Environment, file config.File, configFilePath herokuUpdate, herokuDelete, + sftpRoot, + sftpCreate, + sftpList, + sftpDescribe, + sftpUpdate, + sftpDelete, + statsRoot, statsRegions, statsHistorical, diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index d9aa93638..92b1ad7e3 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -1628,6 +1628,119 @@ COMMANDS --version=VERSION Number of service version -n, --name=NAME The name of the Heroku logging object + logging sftp create --name=NAME --version=VERSION --address=ADDRESS --user=USER --ssh-known-hosts=SSH-KNOWN-HOSTS [] + Create an SFTP logging endpoint on a Fastly service version + + -n, --name=NAME The name of the SFTP logging object. Used as a + primary key for API access + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + --address=ADDRESS The hostname or IPv4 addres + --user=USER The username for the server + --ssh-known-hosts=SSH-KNOWN-HOSTS + A list of host keys for all hosts we can + connect to over SFTP + --port=PORT The port number + --password=PASSWORD The password for the server. If both password + and secret_key are passed, secret_key will be + used in preference + --public-key=PUBLIC-KEY A PGP public key that Fastly will use to + encrypt your log files before writing them to + disk + --secret-key=SECRET-KEY The SSH private key for the server. If both + password and secret_key are passed, secret_key + will be used in preference + --path=PATH The path to upload logs to. The directory must + exist on the SFTP server before logs can be + saved to it + --period=PERIOD How frequently log files are finalized so they + can be available for reading (in seconds, + default 3600) + --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 + --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when + dumping logs (default 0, no compression) + --response-condition=RESPONSE-CONDITION + The name of an existing condition in the + configured endpoint, or leave blank to always + execute + --timestamp-format=TIMESTAMP-FORMAT + strftime specified timestamp formatting + (default "%Y-%m-%dT%H:%M:%S.000") + --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 sftp list --version=VERSION [] + List SFTP endpoints on a Fastly service version + + -s, --service-id=SERVICE-ID Service ID + --version=VERSION Number of service version + + logging sftp describe --version=VERSION --name=NAME [] + Show detailed information about an SFTP 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 SFTP logging object + + logging sftp update --version=VERSION --name=NAME [] + Update an SFTP 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 SFTP logging object + --new-name=NEW-NAME New name of the SFTP logging object + --address=ADDRESS The hostname or IPv4 address + --port=PORT The port number + --public-key=PUBLIC-KEY A PGP public key that Fastly will use to + encrypt your log files before writing them to + disk + --secret-key=SECRET-KEY The SSH private key for the server. If both + password and secret_key are passed, secret_key + will be used in preference + --ssh-known-hosts=SSH-KNOWN-HOSTS + A list of host keys for all hosts we can + connect to over SFTP + --user=USER The username for the server + --password=PASSWORD The password for the server. If both password + and secret_key are passed, secret_key will be + used in preference + --path=PATH The path to upload logs to. The directory must + exist on the SFTP server before logs can be + saved to it + --period=PERIOD How frequently log files are finalized so they + can be available for reading (in seconds, + default 3600) + --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 + --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when + dumping logs (default 0, no compression) + --response-condition=RESPONSE-CONDITION + The name of an existing condition in the + configured endpoint, or leave blank to always + execute + --timestamp-format=TIMESTAMP-FORMAT + strftime specified timestamp formatting + (default "%Y-%m-%dT%H:%M:%S.000") + --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 sftp delete --version=VERSION --name=NAME [] + Delete an SFTP 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 SFTP logging object + stats regions List stats regions diff --git a/pkg/logging/sftp/create.go b/pkg/logging/sftp/create.go new file mode 100644 index 000000000..039d244ce --- /dev/null +++ b/pkg/logging/sftp/create.go @@ -0,0 +1,154 @@ +package sftp + +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 SFTP logging endpoints. +type CreateCommand struct { + common.Base + manifest manifest.Data + + // required + EndpointName string // Can't shaddow common.Base method Name(). + Version int + Address string + User string + SSHKnownHosts string + + // optional + Port common.OptionalUint + Password common.OptionalString + PublicKey common.OptionalString + SecretKey common.OptionalString + Path common.OptionalString + Period common.OptionalUint + Format common.OptionalString + FormatVersion common.OptionalUint + GzipLevel common.OptionalUint + ResponseCondition common.OptionalString + TimestampFormat 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 an SFTP logging endpoint on a Fastly service version").Alias("add") + + c.CmdClause.Flag("name", "The name of the SFTP 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("address", "The hostname or IPv4 addres").Required().StringVar(&c.Address) + c.CmdClause.Flag("user", "The username for the server").Required().StringVar(&c.User) + c.CmdClause.Flag("ssh-known-hosts", "A list of host keys for all hosts we can connect to over SFTP").Required().StringVar(&c.SSHKnownHosts) + + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) + c.CmdClause.Flag("password", "The password for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.Password.Set).StringVar(&c.Password.Value) + c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) + c.CmdClause.Flag("secret-key", "The SSH private key for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.CmdClause.Flag("path", "The path to upload logs to. The directory must exist on the SFTP server before logs can be saved to it").Action(c.Path.Set).StringVar(&c.Path.Value) + c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.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("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.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("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.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.CreateSFTPInput, error) { + var input fastly.CreateSFTPInput + + 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.Address = fastly.String(c.Address) + input.User = fastly.String(c.User) + input.SSHKnownHosts = fastly.String(c.SSHKnownHosts) + + if c.Port.Valid { + input.Port = fastly.Uint(c.Port.Value) + } + + if c.Password.Valid { + input.Password = fastly.String(c.Password.Value) + } + + if c.PublicKey.Valid { + input.PublicKey = fastly.String(c.PublicKey.Value) + } + + if c.SecretKey.Valid { + input.SecretKey = fastly.String(c.SecretKey.Value) + } + + if c.Path.Valid { + input.Path = fastly.String(c.Path.Value) + } + + if c.Period.Valid { + input.Period = fastly.Uint(c.Period.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.GzipLevel.Valid { + input.GzipLevel = fastly.Uint(c.GzipLevel.Value) + } + + if c.ResponseCondition.Valid { + input.ResponseCondition = fastly.String(c.ResponseCondition.Value) + } + + if c.TimestampFormat.Valid { + input.TimestampFormat = fastly.String(c.TimestampFormat.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.CreateSFTP(input) + if err != nil { + return err + } + + text.Success(out, "Created SFTP logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.Version) + return nil +} diff --git a/pkg/logging/sftp/delete.go b/pkg/logging/sftp/delete.go new file mode 100644 index 000000000..e1715fbe6 --- /dev/null +++ b/pkg/logging/sftp/delete.go @@ -0,0 +1,47 @@ +package sftp + +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 SFTP logging endpoints. +type DeleteCommand struct { + common.Base + manifest manifest.Data + Input fastly.DeleteSFTPInput +} + +// 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 an SFTP 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 SFTP 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.DeleteSFTP(&c.Input); err != nil { + return err + } + + text.Success(out, "Deleted SFTP logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.Service, c.Input.Version) + return nil +} diff --git a/pkg/logging/sftp/describe.go b/pkg/logging/sftp/describe.go new file mode 100644 index 000000000..2f25790a6 --- /dev/null +++ b/pkg/logging/sftp/describe.go @@ -0,0 +1,66 @@ +package sftp + +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 an SFTP logging endpoint. +type DescribeCommand struct { + common.Base + manifest manifest.Data + Input fastly.GetSFTPInput +} + +// 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 an SFTP 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 SFTP 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 + + sftp, err := c.Globals.Client.GetSFTP(&c.Input) + if err != nil { + return err + } + + fmt.Fprintf(out, "Service ID: %s\n", sftp.ServiceID) + fmt.Fprintf(out, "Version: %d\n", sftp.Version) + fmt.Fprintf(out, "Name: %s\n", sftp.Name) + fmt.Fprintf(out, "Address: %s\n", sftp.Address) + fmt.Fprintf(out, "Port: %d\n", sftp.Port) + fmt.Fprintf(out, "User: %s\n", sftp.User) + fmt.Fprintf(out, "Password: %s\n", sftp.Password) + fmt.Fprintf(out, "Public key: %s\n", sftp.PublicKey) + fmt.Fprintf(out, "Secret key: %s\n", sftp.SecretKey) + fmt.Fprintf(out, "SSH known hosts: %s\n", sftp.SSHKnownHosts) + fmt.Fprintf(out, "Path: %s\n", sftp.Path) + fmt.Fprintf(out, "Period: %d\n", sftp.Period) + fmt.Fprintf(out, "GZip level: %d\n", sftp.GzipLevel) + fmt.Fprintf(out, "Format: %s\n", sftp.Format) + fmt.Fprintf(out, "Format version: %d\n", sftp.FormatVersion) + fmt.Fprintf(out, "Response condition: %s\n", sftp.ResponseCondition) + fmt.Fprintf(out, "Timestamp format: %s\n", sftp.TimestampFormat) + fmt.Fprintf(out, "Placement: %s\n", sftp.Placement) + + return nil +} diff --git a/pkg/logging/sftp/doc.go b/pkg/logging/sftp/doc.go new file mode 100644 index 000000000..a6c7c710f --- /dev/null +++ b/pkg/logging/sftp/doc.go @@ -0,0 +1,3 @@ +// Package sftp contains commands to inspect and manipulate Fastly service SFTP +// logging endpoints. +package sftp diff --git a/pkg/logging/sftp/list.go b/pkg/logging/sftp/list.go new file mode 100644 index 000000000..be7c5f46c --- /dev/null +++ b/pkg/logging/sftp/list.go @@ -0,0 +1,82 @@ +package sftp + +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 SFTP logging endpoints. +type ListCommand struct { + common.Base + manifest manifest.Data + Input fastly.ListSFTPsInput +} + +// 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 SFTP 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 + + sftps, err := c.Globals.Client.ListSFTPs(&c.Input) + if err != nil { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, sftp := range sftps { + tw.AddLine(sftp.ServiceID, sftp.Version, sftp.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, sftp := range sftps { + fmt.Fprintf(out, "\tSFTP %d/%d\n", i+1, len(sftps)) + fmt.Fprintf(out, "\t\tService ID: %s\n", sftp.ServiceID) + fmt.Fprintf(out, "\t\tVersion: %d\n", sftp.Version) + fmt.Fprintf(out, "\t\tName: %s\n", sftp.Name) + fmt.Fprintf(out, "\t\tAddress: %s\n", sftp.Address) + fmt.Fprintf(out, "\t\tPort: %d\n", sftp.Port) + fmt.Fprintf(out, "\t\tUser: %s\n", sftp.User) + fmt.Fprintf(out, "\t\tPassword: %s\n", sftp.Password) + fmt.Fprintf(out, "\t\tPublic key: %s\n", sftp.PublicKey) + fmt.Fprintf(out, "\t\tSecret key: %s\n", sftp.SecretKey) + fmt.Fprintf(out, "\t\tSSH known hosts: %s\n", sftp.SSHKnownHosts) + fmt.Fprintf(out, "\t\tPath: %s\n", sftp.Path) + fmt.Fprintf(out, "\t\tPeriod: %d\n", sftp.Period) + fmt.Fprintf(out, "\t\tGZip level: %d\n", sftp.GzipLevel) + fmt.Fprintf(out, "\t\tFormat: %s\n", sftp.Format) + fmt.Fprintf(out, "\t\tFormat version: %d\n", sftp.FormatVersion) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", sftp.ResponseCondition) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", sftp.TimestampFormat) + fmt.Fprintf(out, "\t\tPlacement: %s\n", sftp.Placement) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/logging/sftp/root.go b/pkg/logging/sftp/root.go new file mode 100644 index 000000000..aa06c01c8 --- /dev/null +++ b/pkg/logging/sftp/root.go @@ -0,0 +1,28 @@ +package sftp + +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("sftp", "Manipulate Fastly service version SFTP 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/sftp/sftp_integration_test.go b/pkg/logging/sftp/sftp_integration_test.go new file mode 100644 index 000000000..8f07fd5c8 --- /dev/null +++ b/pkg/logging/sftp/sftp_integration_test.go @@ -0,0 +1,523 @@ +package sftp_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 TestSFTPCreate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user", "--ssh-known-hosts", knownHosts(), "--port", "80"}, + wantError: "error parsing arguments: required flag --address not provided", + }, + { + args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--ssh-known-hosts", knownHosts(), "--port", "80"}, + wantError: "error parsing arguments: required flag --user not provided", + }, + { + args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "user", "--port", "80"}, + wantError: "error parsing arguments: required flag --ssh-known-hosts not provided", + }, + { + args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "user", "--ssh-known-hosts", knownHosts(), "--port", "80"}, + api: mock.API{CreateSFTPFn: createSFTPOK}, + wantOutput: "Created SFTP logging endpoint log (service 123 version 1)", + }, + { + args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "user", "--ssh-known-hosts", knownHosts(), "--port", "80"}, + api: mock.API{CreateSFTPFn: createSFTPError}, + 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 TestSFTPList(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListSFTPsFn: listSFTPsOK}, + wantOutput: listSFTPsShortOutput, + }, + { + args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1", "--verbose"}, + api: mock.API{ListSFTPsFn: listSFTPsOK}, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1", "-v"}, + api: mock.API{ListSFTPsFn: listSFTPsOK}, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: []string{"logging", "sftp", "--verbose", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListSFTPsFn: listSFTPsOK}, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: []string{"logging", "-v", "sftp", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListSFTPsFn: listSFTPsOK}, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1"}, + api: mock.API{ListSFTPsFn: listSFTPsError}, + 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 TestSFTPDescribe(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "sftp", "describe", "--service-id", "123", "--version", "1"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"logging", "sftp", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{GetSFTPFn: getSFTPError}, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "sftp", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{GetSFTPFn: getSFTPOK}, + wantOutput: describeSFTPOutput, + }, + } { + 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 TestSFTPUpdate(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, + api: mock.API{ + GetSFTPFn: getSFTPError, + UpdateSFTPFn: updateSFTPOK, + }, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, + api: mock.API{ + GetSFTPFn: getSFTPOK, + UpdateSFTPFn: updateSFTPError, + }, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, + api: mock.API{ + GetSFTPFn: getSFTPOK, + UpdateSFTPFn: updateSFTPOK, + }, + wantOutput: "Updated SFTP 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 TestSFTPDelete(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"logging", "sftp", "delete", "--service-id", "123", "--version", "1"}, + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: []string{"logging", "sftp", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{DeleteSFTPFn: deleteSFTPError}, + wantError: errTest.Error(), + }, + { + args: []string{"logging", "sftp", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, + api: mock.API{DeleteSFTPFn: deleteSFTPOK}, + wantOutput: "Deleted SFTP 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 createSFTPOK(i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { + s := fastly.SFTP{ + ServiceID: i.Service, + Version: i.Version, + } + + if i.Name != nil { + s.Name = *i.Name + } + + return &s, nil +} + +func createSFTPError(i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { + return nil, errTest +} + +func listSFTPsOK(i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { + return []*fastly.SFTP{ + { + ServiceID: i.Service, + Version: i.Version, + Name: "logs", + Address: "127.0.0.1", + Port: 514, + User: "user", + Password: "password", + PublicKey: pgpPublicKey(), + SecretKey: sshPrivateKey(), + SSHKnownHosts: knownHosts(), + Path: "/logs", + Period: 3600, + GzipLevel: 2, + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", + Placement: "none", + }, + { + ServiceID: i.Service, + Version: i.Version, + Name: "analytics", + Address: "example.com", + Port: 123, + User: "user", + Password: "password", + PublicKey: pgpPublicKey(), + SecretKey: sshPrivateKey(), + SSHKnownHosts: knownHosts(), + Path: "/analytics", + Period: 3600, + GzipLevel: 3, + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", + Placement: "none", + }, + }, nil +} + +func listSFTPsError(i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { + return nil, errTest +} + +var listSFTPsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listSFTPsVerboseOutput = strings.TrimSpace(` +Fastly API token not provided +Fastly API endpoint: https://api.fastly.com +Service ID: 123 +Version: 1 + SFTP 1/2 + Service ID: 123 + Version: 1 + Name: logs + Address: 127.0.0.1 + Port: 514 + User: user + Password: password + Public key: `+pgpPublicKey()+` + Secret key: `+sshPrivateKey()+` + SSH known hosts: `+knownHosts()+` + Path: /logs + Period: 3600 + GZip level: 2 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + SFTP 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Address: example.com + Port: 123 + User: user + Password: password + Public key: `+pgpPublicKey()+` + Secret key: `+sshPrivateKey()+` + SSH known hosts: `+knownHosts()+` + Path: /analytics + Period: 3600 + GZip level: 3 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none +`) + "\n\n" + +func getSFTPOK(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { + return &fastly.SFTP{ + ServiceID: i.Service, + Version: i.Version, + Name: "logs", + Address: "example.com", + Port: 514, + User: "user", + Password: "password", + PublicKey: pgpPublicKey(), + SecretKey: sshPrivateKey(), + SSHKnownHosts: knownHosts(), + Path: "/logs", + Period: 3600, + GzipLevel: 2, + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", + Placement: "none", + }, nil +} + +func getSFTPError(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { + return nil, errTest +} + +var describeSFTPOutput = strings.TrimSpace(` +Service ID: 123 +Version: 1 +Name: logs +Address: example.com +Port: 514 +User: user +Password: password +Public key: `+pgpPublicKey()+` +Secret key: `+sshPrivateKey()+` +SSH known hosts: `+knownHosts()+` +Path: /logs +Period: 3600 +GZip level: 2 +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Response condition: Prevent default logging +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +Placement: none +`) + "\n" + +func updateSFTPOK(i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { + return &fastly.SFTP{ + ServiceID: i.Service, + Version: i.Version, + Name: "log", + Address: "example.com", + Port: 514, + User: "user", + Password: "password", + PublicKey: pgpPublicKey(), + SecretKey: sshPrivateKey(), + SSHKnownHosts: knownHosts(), + Path: "/logs", + Period: 3600, + GzipLevel: 3, + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + ResponseCondition: "Prevent default logging", + TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", + Placement: "none", + }, nil +} + +func updateSFTPError(i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { + return nil, errTest +} + +func deleteSFTPOK(i *fastly.DeleteSFTPInput) error { + return nil +} + +func deleteSFTPError(i *fastly.DeleteSFTPInput) error { + return errTest +} + +// knownHosts returns sample known hosts suitable for testing +func knownHosts() string { + return strings.TrimSpace(` +example.com +127.0.0.1 +`) +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} + +// sshPrivateKey returns a private key suitable for testing. +func sshPrivateKey() string { + return strings.TrimSpace(`-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDDo+/YbQ1cZVoRhZ/bbQtPxpycDS5Lty+M8e5swCKpmo0/Eym2 +KrVpEVMoU8eGtwVRvGDR2LtmFKvd86QUWkn2V3lYgY66SNj9n4R/YSDT4/GRkg+4 +Egi++ihpZA+SAIODF4+l1bh/FFu0XUpQLXvJ4Tm0++7bm3tEq+XQr9znrwIDAQAB +AoGAfDa374e9te47s2hNyLmBNxN5F7Nes4AJVsm8gZuz5k9UYrm+AAU5zQ3M6IvY +4PWPEQgzyMh8oyF4xaENikaRMhSMfinUmTd979cHbOM6cEKPk28oQcIybsdSzX7G +ZWRh65Ze1DUmBe6R2BUh3Zn4lq9PsqB0TeZeV7Xo/VaIpFECQQDoznQi8HOY8MNM +7ZDdRhFAkS2X5OGqXOjYdLABGNvJhajgoRsTbgDyJG83qn6yYq7wEHYlMddGZ3ln +RLnpsThjAkEA1yGXae8WURFEqjp5dMLBxU07apKvEF4zK1OxZ0VjIOJdIpoRBBuL +IthGBuMrfbF1W5tlmQlj5ik0KhVpBZoHRQJAZP7DdTDZBT1VjHb3RHcUHu2cWOvL +VkvuG5ErlZ5CIv+gDqr1gw1SzbkuoniNdDfJao3Jo0Mm//z9tuYivRXLvwJBALG3 +Wzi0vI/Nnxas5YayGJaf3XSFpj70QnsJUWUJagFRXjTmZyYohsELPpYT9eqIvXUm +o0BQBImvAhu9whtRia0CQCFdDHdNnyyzKH8vC0NsEN65h3Bp2KEPkv8SOV27ZRR2 +xIGqLusk3y+yzbueLZJ117osdB1Owr19fvAHR7vq6Mw= +-----END RSA PRIVATE KEY-----`) +} diff --git a/pkg/logging/sftp/sftp_test.go b/pkg/logging/sftp/sftp_test.go new file mode 100644 index 000000000..b6f55ebe9 --- /dev/null +++ b/pkg/logging/sftp/sftp_test.go @@ -0,0 +1,313 @@ +package sftp + +import ( + "strings" + "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 TestCreateSFTPInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *CreateCommand + want *fastly.CreateSFTPInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateSFTPInput{ + Service: "123", + Version: 2, + Name: fastly.String("log"), + Address: fastly.String("127.0.0.1"), + User: fastly.String("user"), + SSHKnownHosts: fastly.String(knownHosts()), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateSFTPInput{ + Service: "123", + Version: 2, + Name: fastly.String("log"), + Address: fastly.String("127.0.0.1"), + Port: fastly.Uint(80), + User: fastly.String("user"), + Password: fastly.String("password"), + PublicKey: fastly.String(pgpPublicKey()), + SecretKey: fastly.String(sshPrivateKey()), + SSHKnownHosts: fastly.String(knownHosts()), + Path: fastly.String("/log"), + Period: fastly.Uint(3600), + FormatVersion: fastly.Uint(2), + GzipLevel: fastly.Uint(2), + Format: fastly.String(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.String("Prevent default logging"), + TimestampFormat: fastly.String("%Y-%m-%dT%H:%M:%S.000"), + 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 TestUpdateSFTPInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *UpdateCommand + api mock.API + want *fastly.UpdateSFTPInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{GetSFTPFn: getSFTPOK}, + want: &fastly.UpdateSFTPInput{ + Service: "123", + Version: 2, + Name: "logs", + NewName: fastly.String("new1"), + Address: fastly.String("new2"), + Port: fastly.Uint(81), + User: fastly.String("new3"), + SSHKnownHosts: fastly.String("new4"), + Password: fastly.String("new5"), + PublicKey: fastly.String("new6"), + SecretKey: fastly.String("new7"), + Path: fastly.String("new8"), + Period: fastly.Uint(3601), + FormatVersion: fastly.Uint(3), + GzipLevel: fastly.Uint(3), + Format: fastly.String("new9"), + ResponseCondition: fastly.String("new10"), + TimestampFormat: fastly.String("new11"), + Placement: fastly.String("new12"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{GetSFTPFn: getSFTPOK}, + want: &fastly.UpdateSFTPInput{ + Service: "123", + Version: 2, + Name: "logs", + NewName: fastly.String("logs"), + Address: fastly.String("127.0.0.1"), + Port: fastly.Uint(80), + User: fastly.String("user"), + Password: fastly.String("password"), + PublicKey: fastly.String(pgpPublicKey()), + SecretKey: fastly.String(sshPrivateKey()), + SSHKnownHosts: fastly.String(knownHosts()), + Path: fastly.String("/log"), + Period: fastly.Uint(3600), + FormatVersion: fastly.Uint(2), + GzipLevel: fastly.Uint(2), + Format: fastly.String(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.String("Prevent default logging"), + TimestampFormat: fastly.String("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.String("none"), + }, + }, + { + 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", + Version: 2, + Address: "127.0.0.1", + User: "user", + SSHKnownHosts: knownHosts(), + } +} + +func createCommandAll() *CreateCommand { + return &CreateCommand{ + manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, + EndpointName: "log", + Version: 2, + Address: "127.0.0.1", + User: "user", + SSHKnownHosts: knownHosts(), + Port: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 80}, + Password: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "password"}, + PublicKey: common.OptionalString{Optional: common.Optional{Valid: true}, Value: pgpPublicKey()}, + SecretKey: common.OptionalString{Optional: common.Optional{Valid: true}, Value: sshPrivateKey()}, + Path: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "/log"}, + Period: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 3600}, + 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}, + GzipLevel: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 2}, + ResponseCondition: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "Prevent default logging"}, + TimestampFormat: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + 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"}, + Address: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new2"}, + User: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new3"}, + SSHKnownHosts: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new4"}, + Port: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 81}, + Password: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new5"}, + PublicKey: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new6"}, + SecretKey: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new7"}, + Path: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new8"}, + Period: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 3601}, + Format: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new9"}, + FormatVersion: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 3}, + GzipLevel: common.OptionalUint{Optional: common.Optional{Valid: true}, Value: 3}, + ResponseCondition: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new10"}, + TimestampFormat: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new11"}, + Placement: common.OptionalString{Optional: common.Optional{Valid: true}, Value: "new12"}, + } +} + +func updateCommandMissingServiceID() *UpdateCommand { + res := updateCommandAll() + res.manifest = manifest.Data{} + return res +} + +func getSFTPOK(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { + return &fastly.SFTP{ + ServiceID: i.Service, + Version: i.Version, + Name: "logs", + Address: "127.0.0.1", + Port: 80, + User: "user", + Password: "password", + PublicKey: pgpPublicKey(), + SecretKey: sshPrivateKey(), + SSHKnownHosts: knownHosts(), + Path: "/log", + Period: 3600, + Format: `%h %l %u %t "%r" %>s %b`, + FormatVersion: 2, + GzipLevel: 2, + ResponseCondition: "Prevent default logging", + TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", + Placement: "none", + }, nil +} + +// knownHosts returns sample known hosts suitable for testing +func knownHosts() string { + return strings.TrimSpace(` +example.com +127.0.0.1 +`) +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} + +// sshPrivateKey returns a private key suitable for testing. +func sshPrivateKey() string { + return strings.TrimSpace(`-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDDo+/YbQ1cZVoRhZ/bbQtPxpycDS5Lty+M8e5swCKpmo0/Eym2 +KrVpEVMoU8eGtwVRvGDR2LtmFKvd86QUWkn2V3lYgY66SNj9n4R/YSDT4/GRkg+4 +Egi++ihpZA+SAIODF4+l1bh/FFu0XUpQLXvJ4Tm0++7bm3tEq+XQr9znrwIDAQAB +AoGAfDa374e9te47s2hNyLmBNxN5F7Nes4AJVsm8gZuz5k9UYrm+AAU5zQ3M6IvY +4PWPEQgzyMh8oyF4xaENikaRMhSMfinUmTd979cHbOM6cEKPk28oQcIybsdSzX7G +ZWRh65Ze1DUmBe6R2BUh3Zn4lq9PsqB0TeZeV7Xo/VaIpFECQQDoznQi8HOY8MNM +7ZDdRhFAkS2X5OGqXOjYdLABGNvJhajgoRsTbgDyJG83qn6yYq7wEHYlMddGZ3ln +RLnpsThjAkEA1yGXae8WURFEqjp5dMLBxU07apKvEF4zK1OxZ0VjIOJdIpoRBBuL +IthGBuMrfbF1W5tlmQlj5ik0KhVpBZoHRQJAZP7DdTDZBT1VjHb3RHcUHu2cWOvL +VkvuG5ErlZ5CIv+gDqr1gw1SzbkuoniNdDfJao3Jo0Mm//z9tuYivRXLvwJBALG3 +Wzi0vI/Nnxas5YayGJaf3XSFpj70QnsJUWUJagFRXjTmZyYohsELPpYT9eqIvXUm +o0BQBImvAhu9whtRia0CQCFdDHdNnyyzKH8vC0NsEN65h3Bp2KEPkv8SOV27ZRR2 +xIGqLusk3y+yzbueLZJ117osdB1Owr19fvAHR7vq6Mw= +-----END RSA PRIVATE KEY-----`) +} diff --git a/pkg/logging/sftp/update.go b/pkg/logging/sftp/update.go new file mode 100644 index 000000000..1c7a2144f --- /dev/null +++ b/pkg/logging/sftp/update.go @@ -0,0 +1,193 @@ +package sftp + +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 SFTP logging endpoints. +type UpdateCommand struct { + common.Base + manifest manifest.Data + + // required + EndpointName string + Version int + + // optional + NewName common.OptionalString + Address common.OptionalString + Port common.OptionalUint + PublicKey common.OptionalString + SecretKey common.OptionalString + SSHKnownHosts common.OptionalString + User common.OptionalString + Password common.OptionalString + Path common.OptionalString + Period common.OptionalUint + FormatVersion common.OptionalUint + GzipLevel common.OptionalUint + Format common.OptionalString + ResponseCondition common.OptionalString + TimestampFormat 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 an SFTP 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 SFTP logging object").Short('n').Required().StringVar(&c.EndpointName) + + c.CmdClause.Flag("new-name", "New name of the SFTP logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("address", "The hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) + c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) + c.CmdClause.Flag("secret-key", "The SSH private key for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.CmdClause.Flag("ssh-known-hosts", "A list of host keys for all hosts we can connect to over SFTP").Action(c.SSHKnownHosts.Set).StringVar(&c.SSHKnownHosts.Value) + c.CmdClause.Flag("user", "The username for the server").Action(c.User.Set).StringVar(&c.User.Value) + c.CmdClause.Flag("password", "The password for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.Password.Set).StringVar(&c.Password.Value) + c.CmdClause.Flag("path", "The path to upload logs to. The directory must exist on the SFTP server before logs can be saved to it").Action(c.Path.Set).StringVar(&c.Path.Value) + c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.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("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.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("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.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.UpdateSFTPInput, error) { + serviceID, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return nil, errors.ErrNoServiceID + } + + sftp, err := c.Globals.Client.GetSFTP(&fastly.GetSFTPInput{ + Service: serviceID, + Name: c.EndpointName, + Version: c.Version, + }) + if err != nil { + return nil, err + } + + input := fastly.UpdateSFTPInput{ + Service: sftp.ServiceID, + Version: sftp.Version, + Name: sftp.Name, + NewName: fastly.String(sftp.Name), + Address: fastly.String(sftp.Address), + Port: fastly.Uint(sftp.Port), + PublicKey: fastly.String(sftp.PublicKey), + SecretKey: fastly.String(sftp.SecretKey), + SSHKnownHosts: fastly.String(sftp.SSHKnownHosts), + User: fastly.String(sftp.User), + Password: fastly.String(sftp.Password), + Path: fastly.String(sftp.Path), + Period: fastly.Uint(sftp.Period), + Format: fastly.String(sftp.Format), + FormatVersion: fastly.Uint(sftp.FormatVersion), + GzipLevel: fastly.Uint(uint(sftp.GzipLevel)), // TODO (v2): consistent type. + ResponseCondition: fastly.String(sftp.ResponseCondition), + TimestampFormat: fastly.String(sftp.TimestampFormat), + Placement: fastly.String(sftp.Placement), + } + + if c.NewName.Valid { + input.NewName = fastly.String(c.NewName.Value) + } + + if c.Address.Valid { + input.Address = fastly.String(c.Address.Value) + } + + if c.Port.Valid { + input.Port = fastly.Uint(c.Port.Value) + } + + if c.Password.Valid { + input.Password = fastly.String(c.Password.Value) + } + + if c.PublicKey.Valid { + input.PublicKey = fastly.String(c.PublicKey.Value) + } + + if c.SecretKey.Valid { + input.SecretKey = fastly.String(c.SecretKey.Value) + } + + if c.SSHKnownHosts.Valid { + input.SSHKnownHosts = fastly.String(c.SSHKnownHosts.Value) + } + + if c.User.Valid { + input.User = fastly.String(c.User.Value) + } + + if c.Path.Valid { + input.Path = fastly.String(c.Path.Value) + } + + if c.Period.Valid { + input.Period = fastly.Uint(c.Period.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.GzipLevel.Valid { + input.GzipLevel = fastly.Uint(c.GzipLevel.Value) + } + + if c.ResponseCondition.Valid { + input.ResponseCondition = fastly.String(c.ResponseCondition.Value) + } + + if c.TimestampFormat.Valid { + input.TimestampFormat = fastly.String(c.TimestampFormat.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 + } + + sftp, err := c.Globals.Client.UpdateSFTP(input) + if err != nil { + return err + } + + text.Success(out, "Updated SFTP logging endpoint %s (service %s version %d)", sftp.Name, sftp.ServiceID, sftp.Version) + return nil +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index ffa536107..75254a037 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -121,6 +121,12 @@ type API struct { UpdateHerokuFn func(*fastly.UpdateHerokuInput) (*fastly.Heroku, error) DeleteHerokuFn func(*fastly.DeleteHerokuInput) error + CreateSFTPFn func(*fastly.CreateSFTPInput) (*fastly.SFTP, error) + ListSFTPsFn func(*fastly.ListSFTPsInput) ([]*fastly.SFTP, error) + GetSFTPFn func(*fastly.GetSFTPInput) (*fastly.SFTP, error) + UpdateSFTPFn func(*fastly.UpdateSFTPInput) (*fastly.SFTP, error) + DeleteSFTPFn func(*fastly.DeleteSFTPInput) error + GetUserFn func(*fastly.GetUserInput) (*fastly.User, error) GetRegionsFn func() (*fastly.RegionsResponse, error) @@ -597,6 +603,31 @@ func (m API) DeleteHeroku(i *fastly.DeleteHerokuInput) error { return m.DeleteHerokuFn(i) } +// CreateSFTP implements Interface. +func (m API) CreateSFTP(i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { + return m.CreateSFTPFn(i) +} + +// ListSFTPs implements Interface. +func (m API) ListSFTPs(i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { + return m.ListSFTPsFn(i) +} + +// GetSFTP implements Interface. +func (m API) GetSFTP(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { + return m.GetSFTPFn(i) +} + +// UpdateSFTP implements Interface. +func (m API) UpdateSFTP(i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { + return m.UpdateSFTPFn(i) +} + +// DeleteSFTP implements Interface. +func (m API) DeleteSFTP(i *fastly.DeleteSFTPInput) error { + return m.DeleteSFTPFn(i) +} + // GetUser implements Interface. func (m API) GetUser(i *fastly.GetUserInput) (*fastly.User, error) { return m.GetUserFn(i)