From 2cc45a591c033d3a2f916e081a2b7e75985850be Mon Sep 17 00:00:00 2001 From: Patrick Hamann Date: Wed, 11 Mar 2020 09:37:50 +0000 Subject: [PATCH] Interactive init (#5) * Bump go-fastly to v1.7.0 * Add golang-petname dep * Add NullProgress writer * Add UndoStack to pkg/common * Persist user email to global config file * Make `compute init` accept input interactively and add logic to provision service, domain and backend during initialization. * Surpress gosec G307 on file.Close() defers * Add RunIfError method to UndoStack which unwinds the stack if passed an error * Add text.Description() output helper to print term:description groupings. * Refactor compute init to use new helpers --- .github/workflows/pr_test.yml | 1 + go.mod | 5 +- go.sum | 6 + pkg/api/interface.go | 2 + pkg/app/run_test.go | 20 ++- pkg/common/file.go | 2 +- pkg/common/undo.go | 66 ++++++++ pkg/compute/compute_test.go | 180 ++++++++++++++++++-- pkg/compute/deploy.go | 2 +- pkg/compute/init.go | 276 ++++++++++++++++++++++++++++--- pkg/compute/manifest/manifest.go | 2 +- pkg/compute/validate.go | 2 +- pkg/config/data.go | 1 + pkg/configure/configure_test.go | 115 +++++++++---- pkg/configure/root.go | 11 +- pkg/mock/api.go | 7 + pkg/text/progress.go | 30 ++++ pkg/text/text.go | 11 ++ 18 files changed, 656 insertions(+), 83 deletions(-) create mode 100644 pkg/common/undo.go diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index 586fdafc3..d0648dc62 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -93,6 +93,7 @@ jobs: run: make test shell: bash env: + TEST_COMPUTE_INIT: true TEST_COMPUTE_BUILD: true scan: runs-on: ubuntu-latest diff --git a/go.mod b/go.mod index 56b1d0ae9..c2cdbd933 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,13 @@ require ( github.com/ajg/form v1.5.1 // indirect github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect github.com/blang/semver v3.5.1+incompatible - github.com/fastly/go-fastly v1.3.0 + github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 + github.com/fastly/go-fastly v1.7.0 github.com/fatih/color v1.7.0 github.com/frankban/quicktest v1.5.0 // indirect github.com/google/go-cmp v0.3.1 github.com/google/go-github/v28 v28.1.1 - github.com/google/jsonapi v0.0.0-20181016150055-d0428f63eb51 // indirect + github.com/google/jsonapi v0.0.0-20200226002910-c8283f632fb7 // indirect github.com/hashicorp/go-cleanhttp v0.5.1 // indirect github.com/kennygrant/sanitize v1.2.4 github.com/mattn/go-colorable v0.1.4 // indirect diff --git a/go.sum b/go.sum index 8638a766d..668e432f5 100644 --- a/go.sum +++ b/go.sum @@ -24,10 +24,14 @@ github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyG github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 h1:90Ly+6UfUypEF6vvvW5rQIv9opIL8CbmW9FT20LDQoY= +github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0/go.mod h1:V+Qd57rJe8gd4eiGzZyg4h54VLHmYVVw54iMnlAMrF8= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/fastly/go-fastly v1.3.0 h1:halI6tkEmn5JIeW888P2bNGprGP7OYot+o1akPXvPyY= github.com/fastly/go-fastly v1.3.0/go.mod h1:cBtWXhszIFx9xpzgm9L/3PW3Ixszo153xJBEHJghGUk= +github.com/fastly/go-fastly v1.7.0 h1:IiDMXzoSSJZjQP5VIjv+BU4WMoo3DeMGIy2xm9BDuL8= +github.com/fastly/go-fastly v1.7.0/go.mod h1:cBtWXhszIFx9xpzgm9L/3PW3Ixszo153xJBEHJghGUk= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= @@ -52,6 +56,8 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO github.com/google/jsonapi v0.0.0-20170708005851-46d3ced04344/go.mod h1:XSx4m2SziAqk9DXY9nz659easTq4q6TyrpYd9tHSm0g= github.com/google/jsonapi v0.0.0-20181016150055-d0428f63eb51 h1:k+U8IQj6kj659R+Ahq6YsK03GdUo8qQdTsq5HBzfQwM= github.com/google/jsonapi v0.0.0-20181016150055-d0428f63eb51/go.mod h1:XSx4m2SziAqk9DXY9nz659easTq4q6TyrpYd9tHSm0g= +github.com/google/jsonapi v0.0.0-20200226002910-c8283f632fb7 h1:aQ4kMXDAmP9IRIZHcSKB2orXHGwGiSxH4PX1BzKHR50= +github.com/google/jsonapi v0.0.0-20200226002910-c8283f632fb7/go.mod h1:XSx4m2SziAqk9DXY9nz659easTq4q6TyrpYd9tHSm0g= github.com/hashicorp/go-cleanhttp v0.0.0-20170211013415-3573b8b52aa7/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= diff --git a/pkg/api/interface.go b/pkg/api/interface.go index a77a43ea1..66211ac71 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -57,6 +57,8 @@ type Interface interface { GetBigQuery(*fastly.GetBigQueryInput) (*fastly.BigQuery, error) UpdateBigQuery(*fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) DeleteBigQuery(*fastly.DeleteBigQueryInput) error + + GetUser(*fastly.GetUserInput) (*fastly.User, error) } // Interface assertion, to catch mismatches early. diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index 1f093ba23..407c13ece 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -245,15 +245,17 @@ COMMANDS compute init [] Initialize a new Compute@Edge package locally - -n, --name=NAME Name of package, defaulting to directory name - of the --path destination - -f, --from="https://github.com/fastly/fastly-template-rust-default" - Git repository containing package template - -p, --path=PATH Destination to write the new package, - defaulting to the current directory - -s, --service-id=SERVICE-ID Optional Fastly service ID written to the - package manifest, where this package will be - deployed + -n, --name=NAME Name of package, defaulting to directory name + of the --path destination + -d, --description=DESCRIPTION Description of the package + -a, --author=AUTHOR Author of the package + -f, --from=FROM Git repository containing package template + -p, --path=PATH Destination to write the new package, + defaulting to the current directory + --domain=DOMAIN The name of the domain associated to the + package + --backend=BACKEND A hostname, IPv4, or IPv6 address for the + package backend compute build [] Build a Compute@Edge package locally diff --git a/pkg/common/file.go b/pkg/common/file.go index d1b4f8791..2fa1ef40d 100644 --- a/pkg/common/file.go +++ b/pkg/common/file.go @@ -55,7 +55,7 @@ func CopyFile(src, dst string) (err error) { if err != nil { return fmt.Errorf("error reading source file: %w", err) } - defer in.Close() + defer in.Close() // #nosec G307 // Create destination file for writing. out, err := os.Create(dst) diff --git a/pkg/common/undo.go b/pkg/common/undo.go new file mode 100644 index 000000000..362ecb5eb --- /dev/null +++ b/pkg/common/undo.go @@ -0,0 +1,66 @@ +package common + +import ( + "fmt" + "io" +) + +// UndoFn is a function with no arguments which returns an error or nil. +type UndoFn func() error + +// UndoStack models a simple undo stack which consumers can use to store undo +// stateful functions, such as a function to teardown API state if something +// goes wrong during procedural commands, for example deleting a Fastly service +// after it's been created. +type UndoStack struct { + states []UndoFn +} + +// NewUndoStack constructs a new UndoStack. +func NewUndoStack() *UndoStack { + s := make([]UndoFn, 0, 1) + stack := &UndoStack{ + states: s, + } + return stack +} + +// Pop method pops last added UndoFn element oof the stack and returns it. +// If stack is empty Pop() returns nil. +func (s *UndoStack) Pop() UndoFn { + n := len(s.states) + if n == 0 { + return nil + } + v := s.states[n-1] + s.states = s.states[:n-1] + return v +} + +// Push method pushes an Undoer element onto the UndoStack. +func (s *UndoStack) Push(elem UndoFn) { + s.states = append(s.states, elem) +} + +// Len method returns the number of elements in the UndoStack. +func (s *UndoStack) Len() int { + return len(s.states) +} + +// RunIfError unwinds the stack if a non-nil error is passed, by serially +// calling each UndoFn function state in FIFO order. If any UndoFn returns an +// error, it gets logged to the provided writer. Should be deferrerd, such as: +// +// undoStack := common.NewUndoStack() +// defer undoStack.RunIfError(w, err) +// +func (s *UndoStack) RunIfError(w io.Writer, err error) { + if err == nil { + return + } + for i := len(s.states) - 1; i >= 0; i-- { + if err := s.states[i](); err != nil { + fmt.Fprintf(w, "%w", err) + } + } +} diff --git a/pkg/compute/compute_test.go b/pkg/compute/compute_test.go index 0655ad857..1661846bf 100644 --- a/pkg/compute/compute_test.go +++ b/pkg/compute/compute_test.go @@ -33,20 +33,86 @@ func TestInit(t *testing.T) { for _, testcase := range []struct { name string args []string + configFile config.File + api mock.API wantFiles []string unwantedFiles []string + stdin string wantError string wantOutput []string manifestIncludes string }{ { - name: "unkown repository", - args: []string{"compute", "init", "--from", "https://example.com/template"}, + name: "no token", + args: []string{"compute", "init"}, + wantError: "no token provided", + }, + { + name: "unkown repository", + args: []string{"compute", "init", "--from", "https://example.com/template"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainOK, + CreateBackendFn: createBackendOK, + DeleteServiceFn: deleteServiceOK, + DeleteBackendFn: deleteBackendOK, + DeleteDomainFn: deleteDomainOK, + }, wantError: "error fetching package template: repository not found", }, { - name: "with name", - args: []string{"compute", "init", "--name", "test"}, + name: "create service error", + args: []string{"compute", "init"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceError, + }, + wantError: "error creating service: fixture error", + }, + { + name: "create domain error", + args: []string{"compute", "init"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainError, + DeleteServiceFn: deleteServiceOK, + }, + wantError: "error creating domain: fixture error", + }, + { + name: "create backend error", + args: []string{"compute", "init"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainOK, + CreateBackendFn: createBackendError, + DeleteServiceFn: deleteServiceOK, + DeleteDomainFn: deleteDomainOK, + }, + wantError: "error creating backend: fixture error", + }, + { + name: "with name", + args: []string{"compute", "init", "--name", "test"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainOK, + CreateBackendFn: createBackendOK, + }, wantOutput: []string{ "Initializing...", "Fetching package template...", @@ -55,20 +121,54 @@ func TestInit(t *testing.T) { manifestIncludes: `name = "test"`, }, { - name: "with service", - args: []string{"compute", "init", "--service-id", "test"}, + name: "with description", + args: []string{"compute", "init", "--description", "test"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainOK, + CreateBackendFn: createBackendOK, + }, + wantOutput: []string{ + "Initializing...", + "Fetching package template...", + "Updating package manifest..", + }, + manifestIncludes: `description = "test"`, + }, + { + name: "with author", + args: []string{"compute", "init", "--author", "test@example.com"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainOK, + CreateBackendFn: createBackendOK, + }, wantOutput: []string{ "Initializing...", "Fetching package template...", "Updating package manifest..", }, - manifestIncludes: `service_id = "test"`, + manifestIncludes: `authors = ["test@example.com"]`, }, { - name: "default", - args: []string{"compute", "init"}, + name: "default", + args: []string{"compute", "init"}, + configFile: config.File{Token: "123"}, + api: mock.API{ + GetTokenSelfFn: tokenOK, + GetUserFn: getUserOk, + CreateServiceFn: createServiceOK, + CreateDomainFn: createDomainOK, + CreateBackendFn: createBackendOK, + }, wantFiles: []string{ - "cargo.toml", + "Cargo.toml", "fastly.toml", "src/main.rs", }, @@ -108,12 +208,12 @@ func TestInit(t *testing.T) { var ( args = testcase.args env = config.Environment{} - file = config.File{} + file = testcase.configFile appConfigFile = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) + clientFactory = mock.APIClient(testcase.api) httpClient = http.DefaultClient versioner update.Versioner = nil - in io.Reader = nil + in io.Reader = bytes.NewBufferString(testcase.stdin) buf bytes.Buffer out io.Writer = common.NewSyncWriter(&buf) ) @@ -836,6 +936,60 @@ func copyFile(t *testing.T, fromFilename, toFilename string) { var errTest = errors.New("fixture error") +func tokenOK() (*fastly.Token, error) { return &fastly.Token{}, nil } + +func getUserOk(i *fastly.GetUserInput) (*fastly.User, error) { + return &fastly.User{Login: "test@example.com"}, nil +} + +func createServiceOK(i *fastly.CreateServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ID: "12345", + Name: i.Name, + Type: i.Type, + }, nil +} + +func createServiceError(*fastly.CreateServiceInput) (*fastly.Service, error) { + return nil, errTest +} + +func deleteServiceOK(i *fastly.DeleteServiceInput) error { + return nil +} + +func createDomainOK(i *fastly.CreateDomainInput) (*fastly.Domain, error) { + return &fastly.Domain{ + ServiceID: i.Service, + Version: i.Version, + Name: i.Name, + }, nil +} + +func createDomainError(i *fastly.CreateDomainInput) (*fastly.Domain, error) { + return nil, errTest +} + +func deleteDomainOK(i *fastly.DeleteDomainInput) error { + return nil +} + +func createBackendOK(i *fastly.CreateBackendInput) (*fastly.Backend, error) { + return &fastly.Backend{ + ServiceID: i.Service, + Version: i.Version, + Name: i.Name, + }, nil +} + +func createBackendError(i *fastly.CreateBackendInput) (*fastly.Backend, error) { + return nil, errTest +} + +func deleteBackendOK(i *fastly.DeleteBackendInput) error { + return nil +} + func latestVersionInactiveOk(i *fastly.LatestVersionInput) (*fastly.Version, error) { return &fastly.Version{ServiceID: i.Service, Number: 1, Active: false}, nil } diff --git a/pkg/compute/deploy.go b/pkg/compute/deploy.go index 998e2597a..1f5f20e8e 100644 --- a/pkg/compute/deploy.go +++ b/pkg/compute/deploy.go @@ -158,7 +158,7 @@ func (c *Client) UpdatePackage(serviceID string, v int, path string) error { if err != nil { return fmt.Errorf("error reading package: %w", err) } - defer file.Close() + defer file.Close() // #nosec G307 var body bytes.Buffer w := multipart.NewWriter(&body) diff --git a/pkg/compute/init.go b/pkg/compute/init.go index 7b5d7e1a1..29fc80a81 100644 --- a/pkg/compute/init.go +++ b/pkg/compute/init.go @@ -4,32 +4,59 @@ import ( "crypto/rand" "fmt" "io" + mathRand "math/rand" + "net" "os" "path/filepath" "regexp" + "strconv" + "strings" "time" + "github.com/dustinkirkland/golang-petname" "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" "gopkg.in/src-d/go-git.v4" ) -const defaultTemplate = "https://github.com/fastly/fastly-template-rust-default" +type template struct { + Name string + Path string +} + +const ( + defaultTemplate = "https://github.com/fastly/fastly-template-rust-default.git" + defaultTopLevelDomain = "edgecompute.app" + manageServiceBaseURL = "https://manage.fastly.com/configure/services/" +) var ( + gitRepositoryRegEx = regexp.MustCompile(`((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)(/)?`) + domainNameRegEx = regexp.MustCompile(`(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]`) fastlyOrgRegEx = regexp.MustCompile(`^https:\/\/github\.com\/fastly`) fastlyFileIgnoreListRegEx = regexp.MustCompile(`\.github|LICENSE|SECURITY\.md`) + defaultTemplates = map[int]template{ + 1: { + Name: "Starter kit", + Path: defaultTemplate, + }, + } ) // InitCommand initializes a Compute@Edge project package on the local machine. type InitCommand struct { common.Base - name string - from string - path string - serviceID string + name string + description string + author string + from string + path string + domain string + backend string } // NewInitCommand returns a usable command registered under the parent. @@ -38,27 +65,45 @@ func NewInitCommand(parent common.Registerer, globals *config.Data) *InitCommand c.Globals = globals c.CmdClause = parent.Command("init", "Initialize a new Compute@Edge package locally") c.CmdClause.Flag("name", "Name of package, defaulting to directory name of the --path destination").Short('n').StringVar(&c.name) - c.CmdClause.Flag("from", "Git repository containing package template").Short('f').Default(defaultTemplate).StringVar(&c.from) + c.CmdClause.Flag("description", "Description of the package").Short('d').StringVar(&c.description) + c.CmdClause.Flag("author", "Author of the package").Short('a').StringVar(&c.author) + c.CmdClause.Flag("from", "Git repository containing package template").Short('f').StringVar(&c.from) c.CmdClause.Flag("path", "Destination to write the new package, defaulting to the current directory").Short('p').StringVar(&c.path) - c.CmdClause.Flag("service-id", "Optional Fastly service ID written to the package manifest, where this package will be deployed").Short('s').StringVar(&c.serviceID) + c.CmdClause.Flag("domain", "The name of the domain associated to the package").StringVar(&c.path) + c.CmdClause.Flag("backend", "A hostname, IPv4, or IPv6 address for the package backend").StringVar(&c.path) + return &c } // Exec implements the command interface. func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { + // Exit early if no token configured. + _, source := c.Globals.Token() + if source == config.SourceUndefined { + return errors.ErrNoToken + } + + text.Output(out, "This utility will walk you through creating a Compute@Edge project. It only covers the most common items, and tries to guess sensible defaults.") + text.Break(out) + text.Output(out, "Press ^C at any time to quit.") + text.Break(out) + var progress text.Progress if c.Globals.Verbose() { progress = text.NewVerboseProgress(out) } else { - progress = text.NewQuietProgress(out) + // Use a null progress writer whilst gathering input. + progress = text.NewNullProgress() } - defer func() { if err != nil { progress.Fail() // progress.Done is handled inline } }() + undoStack := common.NewUndoStack() + defer undoStack.RunIfError(out, err) + if c.path == "" { fmt.Fprintf(progress, "--path not specified, using current directory\n") path, err := os.Getwd() @@ -75,19 +120,143 @@ func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { c.path = abspath if c.name == "" { - name := filepath.Base(c.path) - fmt.Fprintf(progress, "--name not specified, using %s\n", name) - c.name = name + c.name = filepath.Base(c.path) + fmt.Fprintf(progress, "--name not specified, using %s\n\n", c.name) + + name, err := text.Input(out, fmt.Sprintf("Name: [%s] ", c.name), in) + if err != nil { + return fmt.Errorf("error reading input: %w", err) + } + if name != "" { + c.name = name + } + } + + if c.description == "" { + c.description, err = text.Input(out, "Description: ", in) + if err != nil { + return fmt.Errorf("error reading input: %w", err) + } + } + + if c.author == "" { + var defaultEmail string + if email := c.Globals.File.Email; email != "" { + defaultEmail = fmt.Sprintf(" [%s]", email) + } + + c.author, err = text.Input(out, fmt.Sprintf("Author:%s ", defaultEmail), in) + if err != nil { + return fmt.Errorf("error reading input %w", err) + } + if c.author == "" { + c.author = defaultEmail + } + } + + if c.from == "" { + text.Output(out, "%s", text.Bold("Template:")) + for i, template := range defaultTemplates { + text.Output(out, "[%d] %s (%s)", i, template.Name, template.Path) + } + template, err := text.Input(out, "Choose option or type URL: [1] ", in, validateTemplateOptionOrURL) + if err != nil { + return fmt.Errorf("error reading input %w", err) + } + if template == "" { + template = "1" + } + if i, err := strconv.Atoi(template); err == nil { + template = defaultTemplates[i].Path + } + c.from = template + } + + if c.domain == "" { + mathRand.Seed(time.Now().UnixNano()) + defaultDomain := fmt.Sprintf("%s.%s", petname.Generate(3, "-"), defaultTopLevelDomain) + c.domain, err = text.Input(out, fmt.Sprintf("Domain: [%s] ", defaultDomain), in, validateDomain) + if err != nil { + return fmt.Errorf("error reading input %w", err) + } + if c.domain == "" { + c.domain = defaultDomain + } + } + + if c.backend == "" { + c.backend, err = text.Input(out, "Backend (originless, hostname or IP address): [originless] ", in, validateBackend) + if err != nil { + return fmt.Errorf("error reading input %w", err) + } + if c.backend == "" || c.backend == "originless" { + c.backend = "127.0.0.1" + } } + text.Break(out) + + if !c.Globals.Verbose() { + progress = text.NewQuietProgress(out) + } + + progress.Step("Creating service...") + service, err := c.Globals.Client.CreateService(&fastly.CreateServiceInput{ + Name: c.name, + Type: "wasm", + Comment: c.description, + }) + if err != nil { + return fmt.Errorf("error creating service: %w", err) + } + undoStack.Push(func() error { + return c.Globals.Client.DeleteService(&fastly.DeleteServiceInput{ + ID: service.ID, + }) + }) + + progress.Step("Creating domain...") + _, err = c.Globals.Client.CreateDomain(&fastly.CreateDomainInput{ + Service: service.ID, + Version: 1, + Name: c.domain, + }) + if err != nil { + return fmt.Errorf("error creating domain: %w", err) + } + undoStack.Push(func() error { + return c.Globals.Client.DeleteDomain(&fastly.DeleteDomainInput{ + Service: service.ID, + Version: 1, + Name: c.domain, + }) + }) + + progress.Step("Creating backend...") + _, err = c.Globals.Client.CreateBackend(&fastly.CreateBackendInput{ + Service: service.ID, + Version: 1, + Name: c.backend, + Address: c.backend, + }) + if err != nil { + return fmt.Errorf("error creating backend: %w", err) + } + undoStack.Push(func() error { + return c.Globals.Client.DeleteBackend(&fastly.DeleteBackendInput{ + Service: service.ID, + Version: 1, + Name: c.backend, + }) + }) + + progress.Step("Fetching package template...") tempdir, err := tempDir("package-init") if err != nil { return fmt.Errorf("error creating temporary path for package template: %w", err) } defer os.RemoveAll(tempdir) - progress.Step("Fetching package template...") - if _, err := git.PlainClone(tempdir, false, &git.CloneOptions{ URL: c.from, Depth: 1, @@ -134,22 +303,39 @@ func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { return fmt.Errorf("error reading package manifest: %w", err) } - if c.name != "" { - fmt.Fprintf(progress, "Setting package name in manifest to %q...\n", c.name) - m.Name = c.name + fmt.Fprintf(progress, "Setting package name in manifest to %q...\n", c.name) + m.Name = c.name + + if c.description != "" { + fmt.Fprintf(progress, "Setting description in manifest to %s...\n", c.description) + m.Description = c.description } - if c.serviceID != "" { - fmt.Fprintf(progress, "Setting service ID in manifest to %q...\n", c.serviceID) - m.ServiceID = c.serviceID + if c.author != "" { + fmt.Fprintf(progress, "Setting author in manifest to %s...\n", c.author) + m.Authors = []string{c.author} } + fmt.Fprintf(progress, "Setting service ID in manifest to %q...\n", service.ID) + m.ServiceID = service.ID + + fmt.Fprintf(progress, "Setting version in manifest to 1...\n") + m.Version = 1 + if err := m.Write(filepath.Join(c.path, ManifestFilename)); err != nil { return fmt.Errorf("error saving package manifest: %w", err) } progress.Done() - text.Success(out, "Initialized package %s to %s", m.Name, abspath) + + text.Break(out) + + text.Description(out, fmt.Sprintf("Initialized package %s to", text.Bold(m.Name)), abspath) + text.Description(out, "Manage this service at", fmt.Sprintf("%s%s", manageServiceBaseURL, service.ID)) + text.Description(out, "To compile the package, run", "fastly compute build") + text.Description(out, "To deploy the package, run", "fastly compute deploy") + + text.Success(out, "Initialized service %s", service.ID) return nil } @@ -173,10 +359,6 @@ func verifyDestination(path string, verbose io.Writer) (abspath string, err erro } } - if _, err := os.Stat(filepath.Join(abspath, ".git")); err == nil { - return abspath, fmt.Errorf("package destination already contains git metadata") - } - if _, err := os.Stat(filepath.Join(abspath, ManifestFilename)); err == nil { return abspath, fmt.Errorf("package destination already contains a package manifest") } @@ -221,3 +403,47 @@ func tempDir(prefix string) (abspath string, err error) { return abspath, nil } + +func validateTemplateOptionOrURL(input string) error { + msg := "must be a valid option or Git URL" + if input == "" { + return nil + } + if option, err := strconv.Atoi(input); err == nil { + if _, ok := defaultTemplates[option]; !ok { + return fmt.Errorf(msg) + } + return nil + } + if !gitRepositoryRegEx.MatchString(input) { + return fmt.Errorf(msg) + } + return nil +} + +func validateBackend(input string) error { + var isHost bool + if _, err := net.LookupHost(input); err == nil { + isHost = true + } + var isAddr bool + if _, err := net.LookupAddr(input); err == nil { + isHost = true + } + isEmpty := input == "" + isOriginless := strings.ToLower(input) == "originless" + if !isEmpty && !isOriginless && !isHost && !isAddr { + return fmt.Errorf(`must be "originless" or a valid hostname, IPv4, or IPv6 address`) + } + return nil +} + +func validateDomain(input string) error { + if input == "" { + return nil + } + if !domainNameRegEx.MatchString(input) { + return fmt.Errorf("must be valid domain name") + } + return nil +} diff --git a/pkg/compute/manifest/manifest.go b/pkg/compute/manifest/manifest.go index 658f5bb2b..751e5feb9 100644 --- a/pkg/compute/manifest/manifest.go +++ b/pkg/compute/manifest/manifest.go @@ -61,7 +61,7 @@ func (d *Data) ServiceID() (string, Source) { // File represents all of the configuration parameters in the fastly.toml // manifest file schema. type File struct { - Version string `toml:"version"` + Version int `toml:"version"` Name string `toml:"name"` Description string `toml:"description"` Authors []string `toml:"authors"` diff --git a/pkg/compute/validate.go b/pkg/compute/validate.go index 31f9095bb..f3e87313e 100644 --- a/pkg/compute/validate.go +++ b/pkg/compute/validate.go @@ -22,7 +22,7 @@ func validate(path string) error { if err != nil { return fmt.Errorf("error reading package: %w", err) } - defer file.Close() + defer file.Close() // #nosec G307 tar := archiver.NewTarGz() err = tar.Open(file, 0) diff --git a/pkg/config/data.go b/pkg/config/data.go index 556c54ea1..749a3fb18 100644 --- a/pkg/config/data.go +++ b/pkg/config/data.go @@ -114,6 +114,7 @@ const DefaultEndpoint = "https://api.fastly.com" // config file. At some point, it may expand to include e.g. user profiles. type File struct { Token string `toml:"token"` + Email string `toml:"email"` Endpoint string `toml:"endpoint"` LastVersionCheck string `toml:"last_version_check"` } diff --git a/pkg/configure/configure_test.go b/pkg/configure/configure_test.go index fdf8eacef..f22b4beb6 100644 --- a/pkg/configure/configure_test.go +++ b/pkg/configure/configure_test.go @@ -25,6 +25,12 @@ func TestConfigure(t *testing.T) { var ( goodToken = func() (*fastly.Token, error) { return &fastly.Token{}, nil } badToken = func() (*fastly.Token, error) { return nil, errors.New("bad token") } + goodUser = func(*fastly.GetUserInput) (*fastly.User, error) { + return &fastly.User{ + Login: "test@example.com", + }, nil + } + badUser = func(*fastly.GetUserInput) (*fastly.User, error) { return nil, errors.New("bad user") } ) for _, testcase := range []struct { @@ -32,17 +38,20 @@ func TestConfigure(t *testing.T) { args []string env config.Environment file config.File + api mock.API configFileData string - tokenfn func() (*fastly.Token, error) stdin string wantError string wantOutput []string - wantFile string + wantFile []string }{ { - name: "endpoint from flag", - args: []string{"configure", "--endpoint=http://local.dev", "--token=abcdef"}, - tokenfn: goodToken, + name: "endpoint from flag", + args: []string{"configure", "--endpoint=http://local.dev", "--token=abcdef"}, + api: mock.API{ + GetTokenSelfFn: goodToken, + GetUserFn: goodUser, + }, wantOutput: []string{ "Fastly API endpoint (via --endpoint): http://local.dev", "Fastly API token (via --token): abcdef", @@ -50,14 +59,22 @@ func TestConfigure(t *testing.T) { "Configured the Fastly CLI", "You can find your configuration file at", }, - wantFile: "token = \"abcdef\"\nendpoint = \"http://local.dev\"\nlast_version_check = \"\"\n", + wantFile: []string{ + `token = "abcdef"`, + `email = "test@example.com"`, + `endpoint = "http://local.dev"`, + `last_version_check = ""`, + }, }, { name: "endpoint already in file should be replaced by flag", args: []string{"configure", "--endpoint=http://staging.dev", "--token=abcdef"}, configFileData: "endpoint = \"https://api.fastly.com\"", stdin: "new_token\n", - tokenfn: goodToken, + api: mock.API{ + GetTokenSelfFn: goodToken, + GetUserFn: goodUser, + }, wantOutput: []string{ "Fastly API endpoint (via --endpoint): http://staging.dev", "Fastly API token (via --token): abcdef", @@ -65,25 +82,41 @@ func TestConfigure(t *testing.T) { "Configured the Fastly CLI", "You can find your configuration file at", }, - wantFile: "token = \"abcdef\"\nendpoint = \"http://staging.dev\"\nlast_version_check = \"\"\n", + wantFile: []string{ + `token = "abcdef"`, + `email = "test@example.com"`, + `endpoint = "http://staging.dev"`, + `last_version_check = ""`, + }, }, { - name: "token from flag", - args: []string{"configure", "--token=abcdef"}, - tokenfn: goodToken, + name: "token from flag", + args: []string{"configure", "--token=abcdef"}, + api: mock.API{ + GetTokenSelfFn: goodToken, + GetUserFn: goodUser, + }, wantOutput: []string{ "Fastly API token (via --token): abcdef", "Validating token...", "Configured the Fastly CLI", "You can find your configuration file at", }, - wantFile: "token = \"abcdef\"\nendpoint = \"https://api.fastly.com\"\nlast_version_check = \"\"\n", + wantFile: []string{ + `token = "abcdef"`, + `email = "test@example.com"`, + `endpoint = "https://api.fastly.com"`, + `last_version_check = ""`, + }, }, { - name: "token from interactive input", - args: []string{"configure"}, - stdin: "1234\n", - tokenfn: goodToken, + name: "token from interactive input", + args: []string{"configure"}, + stdin: "1234\n", + api: mock.API{ + GetTokenSelfFn: goodToken, + GetUserFn: goodUser, + }, wantOutput: []string{ "An API token is used to authenticate requests to the Fastly API. To create a token, visit", "https://manage.fastly.com/account/personal/tokens", @@ -92,27 +125,43 @@ func TestConfigure(t *testing.T) { "Configured the Fastly CLI", "You can find your configuration file at", }, - wantFile: "token = \"1234\"\nendpoint = \"https://api.fastly.com\"\nlast_version_check = \"\"\n", + wantFile: []string{ + `token = "1234"`, + `email = "test@example.com"`, + `endpoint = "https://api.fastly.com"`, + `last_version_check = ""`, + }, }, { - name: "token from flag", - args: []string{"configure"}, - env: config.Environment{Token: "hello"}, - tokenfn: goodToken, + name: "token from flag", + args: []string{"configure"}, + env: config.Environment{Token: "hello"}, + api: mock.API{ + GetTokenSelfFn: goodToken, + GetUserFn: goodUser, + }, wantOutput: []string{ "Fastly API token (via FASTLY_API_TOKEN): hello", "Validating token...", "Configured the Fastly CLI", "You can find your configuration file at", }, - wantFile: "token = \"hello\"\nendpoint = \"https://api.fastly.com\"\nlast_version_check = \"\"\n", + wantFile: []string{ + `token = "hello"`, + `email = "test@example.com"`, + `endpoint = "https://api.fastly.com"`, + `last_version_check = ""`, + }, }, { name: "token already in file should trigger interactive input", args: []string{"configure"}, configFileData: "token = \"old_token\"", stdin: "new_token\n", - tokenfn: goodToken, + api: mock.API{ + GetTokenSelfFn: goodToken, + GetUserFn: goodUser, + }, wantOutput: []string{ "An API token is used to authenticate requests to the Fastly API. To create a token, visit", "https://manage.fastly.com/account/personal/tokens", @@ -121,12 +170,20 @@ func TestConfigure(t *testing.T) { "Configured the Fastly CLI", "You can find your configuration file at", }, - wantFile: "token = \"new_token\"\nendpoint = \"https://api.fastly.com\"\nlast_version_check = \"\"\n", + wantFile: []string{ + `token = "new_token"`, + `email = "test@example.com"`, + `endpoint = "https://api.fastly.com"`, + `last_version_check = ""`, + }, }, { - name: "invalid token", - args: []string{"configure", "--token=abcdef"}, - tokenfn: badToken, + name: "invalid token", + args: []string{"configure", "--token=abcdef"}, + api: mock.API{ + GetTokenSelfFn: badToken, + GetUserFn: badUser, + }, wantOutput: []string{ "Fastly API token (via --token): abcdef", "Validating token...", @@ -142,7 +199,7 @@ func TestConfigure(t *testing.T) { args = testcase.args env = testcase.env file = testcase.file - clientFactory = mock.APIClient(mock.API{GetTokenSelfFn: testcase.tokenfn}) + clientFactory = mock.APIClient(testcase.api) httpClient = http.DefaultClient versioner update.Versioner = nil in io.Reader = strings.NewReader(testcase.stdin) @@ -156,7 +213,7 @@ func TestConfigure(t *testing.T) { if testcase.wantError == "" { p, err := ioutil.ReadFile(configFilePath) testutil.AssertNoError(t, err) - testutil.AssertString(t, testcase.wantFile, string(p)) + testutil.AssertString(t, strings.Join(testcase.wantFile, "\n")+"\n", string(p)) } }) } diff --git a/pkg/configure/root.go b/pkg/configure/root.go index 13f0ed09f..00d49dd41 100644 --- a/pkg/configure/root.go +++ b/pkg/configure/root.go @@ -12,6 +12,7 @@ import ( "github.com/fastly/cli/pkg/common" "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/fastly" ) // APIClientFactory allows the configure command to regenerate the global Fastly @@ -78,12 +79,20 @@ func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { if err != nil { return fmt.Errorf("error regenerating Fastly API client: %w", err) } - if _, err := client.GetTokenSelf(); err != nil { + t, err := client.GetTokenSelf() + if err != nil { return fmt.Errorf("error validating token: %w", err) } + user, err := client.GetUser(&fastly.GetUserInput{ + ID: t.UserID, + }) + if err != nil { + return fmt.Errorf("error fetching token user: %w", err) + } // Set everything in the File struct based on provided user input. c.Globals.File.Token = token + c.Globals.File.Email = user.Login c.Globals.File.Endpoint = endpoint // Make sure the config file directory exists. diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 53b967eac..cad74459e 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -48,6 +48,8 @@ type API struct { GetBigQueryFn func(*fastly.GetBigQueryInput) (*fastly.BigQuery, error) UpdateBigQueryFn func(*fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) DeleteBigQueryFn func(*fastly.DeleteBigQueryInput) error + + GetUserFn func(*fastly.GetUserInput) (*fastly.User, error) } // GetTokenSelf implements Interface. @@ -219,3 +221,8 @@ func (m API) UpdateBigQuery(i *fastly.UpdateBigQueryInput) (*fastly.BigQuery, er func (m API) DeleteBigQuery(i *fastly.DeleteBigQueryInput) error { return m.DeleteBigQueryFn(i) } + +// GetUser implements Interface. +func (m API) GetUser(i *fastly.GetUserInput) (*fastly.User, error) { + return m.GetUserFn(i) +} diff --git a/pkg/text/progress.go b/pkg/text/progress.go index 573f5d906..1f713d99b 100644 --- a/pkg/text/progress.go +++ b/pkg/text/progress.go @@ -234,3 +234,33 @@ func (p *VerboseProgress) Done() {} // Fail implements the Progress interface. It's a no-op. func (p *VerboseProgress) Fail() {} + +// +// +// + +// NullProgress is an implementation of Progress which discards everything +// written into it and produces no output. +type NullProgress struct{} + +// NewNullProgress returns a NullProgress. +func NewNullProgress() *NullProgress { + return &NullProgress{} +} + +// Tick implements the Progress interface. It's a no-op. +func (p *NullProgress) Tick(r rune) {} + +// Tick implements the Progress interface. +func (p *NullProgress) Write(buf []byte) (int, error) { + return 0, nil +} + +// Step implements the Progress interface. +func (p *NullProgress) Step(msg string) {} + +// Done implements the Progress interface. It's a no-op. +func (p *NullProgress) Done() {} + +// Fail implements the Progress interface. It's a no-op. +func (p *NullProgress) Fail() {} diff --git a/pkg/text/text.go b/pkg/text/text.go index b22a7ebc6..16e7dd087 100644 --- a/pkg/text/text.go +++ b/pkg/text/text.go @@ -150,3 +150,14 @@ func Success(w io.Writer, format string, args ...interface{}) { format = strings.TrimRight(format, "\r\n") + "\n" fmt.Fprintf(w, "\n"+BoldGreen("SUCCESS: ")+format, args...) } + +// Description formats the output of a description item. A description item +// consists of a `term` and a `description`. Emphasis is placed on the +// `description` using Bold(). For example: +// +// To compile the package, run: +// fastly compute build +// +func Description(w io.Writer, term, description string) { + fmt.Fprintf(w, "%s:\n\t%s\n\n", term, Bold(description)) +}