diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bcdc79b0..46a00d40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Enhancements: - feat(service/backend): Add support for IAM authentication ([#1963](https://github.com/fastly/cli/pull/1963)) +- feat(vcl): add `vcl check`, `vcl run`, and `vcl serve` to compile and test VCL code ([#1964](https://github.com/fastly/cli/pull/1964)) ### Dependencies: diff --git a/pkg/app/run.go b/pkg/app/run.go index 5dd84fd46..9b961fc46 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -767,6 +767,8 @@ func commandRequiresToken(command argparser.Command) bool { return false case "compute build", "compute hash-files", "compute install-tools", "compute metadata", "compute pack", "compute serve", "compute validate": return false + case "vcl check", "vcl run", "vcl serve": + return false } commandName = strings.Split(commandName, " ")[0] switch commandName { diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index 5edca1b92..c67bb441d 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -101,6 +101,7 @@ update user version whoami +vcl `, }, } diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 9eb14af24..7f8530079 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -200,6 +200,7 @@ import ( domainTools "github.com/fastly/cli/pkg/commands/tools/domain" "github.com/fastly/cli/pkg/commands/update" "github.com/fastly/cli/pkg/commands/user" + vcltesting "github.com/fastly/cli/pkg/commands/vcl" "github.com/fastly/cli/pkg/commands/version" "github.com/fastly/cli/pkg/commands/whoami" "github.com/fastly/cli/pkg/env" @@ -1043,21 +1044,24 @@ func Define( // nolint:revive // function-length aliasServiceAuthDescribe := aliasserviceauth.NewDescribeCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthList := aliasserviceauth.NewListCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthUpdate := aliasserviceauth.NewUpdateCommand(aliasServiceAuthRoot.CmdClause, data) - aliasVclRoot := aliasvcl.NewRootCommand(app, data) - aliasVclDescribe := aliasvcl.NewDescribeCommand(aliasVclRoot.CmdClause, data) - aliasVclConditionRoot := aliasvclcondition.NewRootCommand(aliasVclRoot.CmdClause, data) + vclRoot := vcltesting.NewRootCommand(app, data) + vclCheck := vcltesting.NewCheckCommand(vclRoot.CmdClause, data) + vclRun := vcltesting.NewRunCommand(vclRoot.CmdClause, data) + vclServe := vcltesting.NewServeCommand(vclRoot.CmdClause, data) + aliasVclDescribe := aliasvcl.NewDescribeCommand(vclRoot.CmdClause, data) + aliasVclConditionRoot := aliasvclcondition.NewRootCommand(vclRoot.CmdClause, data) aliasVclConditionCreate := aliasvclcondition.NewCreateCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionDelete := aliasvclcondition.NewDeleteCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionDescribe := aliasvclcondition.NewDescribeCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionList := aliasvclcondition.NewListCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionUpdate := aliasvclcondition.NewUpdateCommand(aliasVclConditionRoot.CmdClause, data) - aliasVclCustomRoot := aliasvclcustom.NewRootCommand(aliasVclRoot.CmdClause, data) + aliasVclCustomRoot := aliasvclcustom.NewRootCommand(vclRoot.CmdClause, data) aliasVclCustomCreate := aliasvclcustom.NewCreateCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomDelete := aliasvclcustom.NewDeleteCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomDescribe := aliasvclcustom.NewDescribeCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomList := aliasvclcustom.NewListCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomUpdate := aliasvclcustom.NewUpdateCommand(aliasVclCustomRoot.CmdClause, data) - aliasVclSnippetRoot := aliasvclsnippet.NewRootCommand(aliasVclRoot.CmdClause, data) + aliasVclSnippetRoot := aliasvclsnippet.NewRootCommand(vclRoot.CmdClause, data) aliasVclSnippetCreate := aliasvclsnippet.NewCreateCommand(aliasVclSnippetRoot.CmdClause, data) aliasVclSnippetDelete := aliasvclsnippet.NewDeleteCommand(aliasVclSnippetRoot.CmdClause, data) aliasVclSnippetDescribe := aliasvclsnippet.NewDescribeCommand(aliasVclSnippetRoot.CmdClause, data) @@ -2012,6 +2016,9 @@ func Define( // nolint:revive // function-length aliasServiceAuthDescribe, aliasServiceAuthList, aliasServiceAuthUpdate, + vclCheck, + vclRun, + vclServe, aliasVclDescribe, aliasVclConditionCreate, aliasVclConditionDelete, diff --git a/pkg/commands/vcl/check.go b/pkg/commands/vcl/check.go new file mode 100644 index 000000000..92f2806b4 --- /dev/null +++ b/pkg/commands/vcl/check.go @@ -0,0 +1,70 @@ +package vcl + +import ( + "context" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CheckCommand compiles VCL with the real Fastly compiler and reports any problems, without running it. +type CheckCommand struct { + argparser.Base + argparser.JSONOutput + + spec specFlags + files []string +} + +// NewCheckCommand returns a usable command registered under the parent. +func NewCheckCommand(parent argparser.Registerer, g *global.Data) *CheckCommand { + c := CheckCommand{ + Base: argparser.Base{ + Globals: g, + SuppressVerbose: true, + }, + } + c.CmdClause = parent.Command("check", "Check that VCL compiles, using the real Fastly VCL compiler (no service or API token needed)") + + // Optional. + c.CmdClause.Arg("file", "VCL files holding subroutine bodies, named after their subroutine (recv.vcl, fetch.vcl, ...)").StringsVar(&c.files) + c.spec.register(c.CmdClause) + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// Exec invokes the application logic for the command. +func (c *CheckCommand) Exec(_ io.Reader, out io.Writer) error { + vcl, sources, err := c.spec.buildVCL(c.files) + if err != nil { + return err + } + origins, err := c.spec.buildOrigins() + if err != nil { + return err + } + + state := loadSandboxState(c.Globals) + saved, err := publish(context.Background(), c.spec.client(c.Globals), newSpec(origins, vcl), &state) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + diags := flattenDiagnostics(saved.LintStatus, sources) + if ok, err := c.WriteJSON(out, compileResult{saved.Valid, diags}); ok { + if err == nil && !saved.Valid { + return errCompileFailed + } + return err + } + + printDiagnostics(out, diags) + if !saved.Valid { + return errCompileFailed + } + text.Success(out, "VCL compiled cleanly.") + return nil +} diff --git a/pkg/commands/vcl/common.go b/pkg/commands/vcl/common.go new file mode 100644 index 000000000..4439e0816 --- /dev/null +++ b/pkg/commands/vcl/common.go @@ -0,0 +1,293 @@ +// Package vcl has the commands that test VCL on real Fastly edge nodes, without needing a service. +// +// They use Fiddle (https://fiddle.fastly.dev) under the hood, but only talk about VCL files, origins, and requests. +// That way, a local engine can replace Fiddle later without breaking anyone. +package vcl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/rand/v2" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/fastly/kingpin" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/fiddle" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/useragent" +) + +// defaultOrigin echoes requests back, which is handy when you're testing VCL and not an origin. +const defaultOrigin = "https://http-me.fastly.dev" + +// specFlags are the flags that describe the VCL and its origins. +type specFlags struct { + origins []string + slotFiles map[string]*string + endpoint string +} + +// slotHelp is the --help text for each subroutine flag. +var slotHelp = map[string]string{ + "init": "one-time setup: backends, ACLs, tables", + "recv": "request inspection and routing", + "hash": "cache key composition", + "hit": "on cache hit", + "miss": "on cache miss, before the origin fetch", + "pass": "on explicit pass", + "fetch": "origin response handling (beresp)", + "error": "synthetic and error responses", + "deliver": "final response mutations (resp)", + "log": "logging", +} + +func (s *specFlags) register(cmd *kingpin.CmdClause) { + cmd.Flag("origin", fmt.Sprintf("Origin server URL, including scheme (repeatable, up to %d). Origins become VCL backends F_origin_0, F_origin_1, ... in flag order, with the first as the default backend. Defaults to %s, a test origin that echoes requests back", fiddle.MaxOrigins, defaultOrigin)).PlaceHolder("URL").StringsVar(&s.origins) + s.slotFiles = map[string]*string{} + for _, slot := range fiddle.Subroutines { + v := new(string) + s.slotFiles[slot] = v + cmd.Flag(slot, fmt.Sprintf("VCL file with the body of vcl_%s (%s)", slot, slotHelp[slot])).PlaceHolder("FILE").StringVar(v) + } + cmd.Flag("sandbox-endpoint", "Base URL of the sandbox service").Default(fiddle.DefaultEndpoint).Hidden().StringVar(&s.endpoint) +} + +var subWrapper = regexp.MustCompile(`(?m)^\s*sub\s+vcl_(\w+)\s*\{`) + +// vclSources maps each subroutine to the file its body comes from. +type vclSources map[string]string + +// buildVCL reads the VCL files given as flags or arguments. +// Arguments are matched to a subroutine by file name, like recv.vcl or vcl_recv.vcl. +func (s *specFlags) buildVCL(positional []string) (map[string]string, vclSources, error) { + sources := vclSources{} + for slot, f := range s.slotFiles { + if *f != "" { + sources[slot] = *f + } + } + for _, path := range positional { + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + slot := strings.TrimPrefix(base, "vcl_") + if !slices.Contains(fiddle.Subroutines, slot) { + return nil, nil, fsterr.RemediationError{ + Inner: fmt.Errorf("cannot tell which subroutine %q holds", path), + Remediation: fmt.Sprintf("Name the file after its subroutine (e.g. recv.vcl, fetch.vcl) or pass it with an explicit flag (e.g. --recv=%s). Subroutines: %s.", path, strings.Join(fiddle.Subroutines, ", ")), + } + } + if prev, dup := sources[slot]; dup { + return nil, nil, fmt.Errorf("both %s and %s provide vcl_%s", prev, path, slot) + } + sources[slot] = path + } + if len(sources) == 0 { + return nil, nil, fsterr.RemediationError{ + Inner: fmt.Errorf("no VCL given"), + Remediation: "Pass at least one VCL file, either positionally (recv.vcl) or with a subroutine flag (--recv=FILE).", + } + } + vcl, err := readSources(sources) + if err != nil { + return nil, nil, err + } + return vcl, sources, nil +} + +// readSources reads the subroutine bodies. +// It's separate from buildVCL so that serve --watch can reload the same files. +func readSources(sources vclSources) (map[string]string, error) { + vcl := map[string]string{} + for slot, path := range sources { + data, err := os.ReadFile(path) // #nosec G304 (user-supplied path) + if err != nil { + return nil, err + } + body := string(data) + if m := subWrapper.FindStringSubmatch(body); m != nil { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("%s contains a subroutine declaration (sub vcl_%s { ... })", path, m[1]), + Remediation: "Provide only the body of the subroutine; the sandbox adds the sub wrapper and the Fastly boilerplate itself.", + } + } + vcl[slot] = body + } + return vcl, nil +} + +// buildOrigins checks the --origin flags, and falls back to the test origin when there are none. +func (s *specFlags) buildOrigins() ([]string, error) { + if len(s.origins) == 0 { + return []string{defaultOrigin}, nil + } + if len(s.origins) > fiddle.MaxOrigins { + return nil, fmt.Errorf("at most %d origins are supported", fiddle.MaxOrigins) + } + for _, o := range s.origins { + u, err := url.Parse(o) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return nil, fmt.Errorf("origin %q must be a URL with an http or https scheme", o) + } + } + return s.origins, nil +} + +func (s *specFlags) client(g *global.Data) *fiddle.Client { + return &fiddle.Client{ + Endpoint: s.endpoint, + HTTPClient: g.HTTPClient, + UserAgent: useragent.Name, + } +} + +// printOrigins shows the backend-to-origin mapping when it isn't obvious. +func printOrigins(out io.Writer, origins []string, defaulted bool) { + if defaulted { + text.Info(out, "No --origin given; requests go to the test origin %s.", defaultOrigin) + return + } + if len(origins) > 1 { + for i, o := range origins { + text.Output(out, "Backend F_origin_%d = %s", i, o) + } + } +} + +// lastCacheHop turns an X-Cache value like "MISS, HIT" into the part the client saw. +func lastCacheHop(v string) string { + parts := strings.Split(v, ",") + return strings.TrimSpace(parts[len(parts)-1]) +} + +// freshCacheID picks a new cache bucket, which is how we get a cold cache. +func freshCacheID() int { + return rand.IntN(1_000_000_000) + 1 // #nosec G404 (bucket name, not security) +} + +// diagnostic is a compiler message that doesn't depend on Fiddle's format. +type diagnostic struct { + Subroutine string `json:"subroutine"` + File string `json:"file,omitempty"` + Line int `json:"line"` + Level string `json:"level"` + Message string `json:"message"` + Excerpt string `json:"excerpt,omitempty"` +} + +// compileResult is the --json output for a compile, used by check and by run when the VCL doesn't compile. +type compileResult struct { + Valid bool `json:"valid"` + Diagnostics []diagnostic `json:"diagnostics"` +} + +// flattenDiagnostics turns a lint report into a sorted list, with the file each message is about. +func flattenDiagnostics(lint map[string][]fiddle.Diagnostic, sources vclSources) []diagnostic { + var out []diagnostic + slots := make([]string, 0, len(lint)) + for slot := range lint { + slots = append(slots, slot) + } + sort.Strings(slots) + for _, slot := range slots { + for _, d := range lint[slot] { + out = append(out, diagnostic{ + Subroutine: slot, + File: sources[slot], + Line: d.Line + 1, + Level: d.Level, + Message: d.Message, + Excerpt: d.Str, + }) + } + } + return out +} + +// printDiagnostics prints "file:line: level: message" lines that editors can jump to. +func printDiagnostics(out io.Writer, diags []diagnostic) { + for _, d := range diags { + where := d.File + if where == "" { + where = "vcl_" + d.Subroutine + } + msg := d.Message + if d.Excerpt != "" { + msg = fmt.Sprintf("%s: %s", msg, d.Excerpt) + } + text.Output(out, "%s:%d: %s: %s", where, d.Line, d.Level, msg) + } +} + +// errCompileFailed is returned after compile diagnostics have been printed. +var errCompileFailed = fsterr.RemediationError{ + Inner: fmt.Errorf("the VCL failed to compile"), + Remediation: "Fix the problems reported above and re-run the command.", +} + +// sandboxState remembers the user's sandbox, so the next run can update it in seconds instead of waiting minutes for a new one to reach the edge. +// If the file gets lost, the next run just starts from scratch. +type sandboxState struct { + ID string `json:"id"` + // CacheID is shared across runs, so running the same request twice can show a MISS and then a HIT. + // --fresh-cache picks a new one. + CacheID int `json:"cache_id"` + + path string +} + +// loadSandboxState reads the state file, which lives next to the CLI config file. +func loadSandboxState(g *global.Data) sandboxState { + s := sandboxState{path: filepath.Join(filepath.Dir(g.ConfigPath), "vcl-sandbox.json")} + if data, err := os.ReadFile(s.path); err == nil { // #nosec G304 + _ = json.Unmarshal(data, &s) + } + return s +} + +func saveSandboxState(s sandboxState) { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return + } + data, _ := json.Marshal(s) + _ = os.WriteFile(s.path, data, 0o600) +} + +// newSpec builds a sandbox spec. +// The title tells anyone who finds the fiddle in the web UI where it came from. +func newSpec(origins []string, vcl map[string]string, requests ...fiddle.Request) fiddle.Spec { + return fiddle.Spec{ + Type: "vcl", + Title: "fastly CLI test sandbox", + Origins: origins, + VCL: vcl, + Requests: requests, + } +} + +// publish updates the user's sandbox, or creates a new one if there isn't one yet or it's gone. +// A new sandbox ID gets saved to the state file. +func publish(ctx context.Context, client *fiddle.Client, spec fiddle.Spec, state *sandboxState) (*fiddle.SavedFiddle, error) { + if state.ID != "" { + if saved, err := client.Update(ctx, state.ID, spec); err == nil { + return saved, nil + } + } + saved, err := client.Create(ctx, spec) + if err != nil { + return nil, err + } + if saved.ID != "" && saved.ID != state.ID { + state.ID = saved.ID + saveSandboxState(*state) + } + return saved, nil +} diff --git a/pkg/commands/alias/vcl/root.go b/pkg/commands/vcl/root.go similarity index 84% rename from pkg/commands/alias/vcl/root.go rename to pkg/commands/vcl/root.go index bc4e3d148..047a749e9 100644 --- a/pkg/commands/alias/vcl/root.go +++ b/pkg/commands/vcl/root.go @@ -21,7 +21,7 @@ const CommandName = "vcl" func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { var c RootCommand c.Globals = g - c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version VCL").Hidden() + c.CmdClause = parent.Command(CommandName, "Test VCL on real Fastly edge nodes before deploying, and manipulate service version VCL") return &c } diff --git a/pkg/commands/vcl/run.go b/pkg/commands/vcl/run.go new file mode 100644 index 000000000..52e38505a --- /dev/null +++ b/pkg/commands/vcl/run.go @@ -0,0 +1,415 @@ +package vcl + +import ( + "context" + "fmt" + "io" + "os" + "slices" + "sort" + "strings" + "time" + + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/fiddle" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// clientFromCountries maps the --client-from country names to Fiddle's codes. +// The codes work too. +var clientFromCountries = map[string]string{ + "brazil": "br", + "china": "cn", + "germany": "de", + "japan": "jp", + "russia": "ru", + "south-africa": "za", + "united-kingdom": "uk", + "united-states": "us", +} + +// clientFromValues is for shell completion, and clientFromHelp lists the countries in --help. +var clientFromValues, clientFromHelp = func() ([]string, string) { + countries := make([]string, 0, len(clientFromCountries)) + for name := range clientFromCountries { + countries = append(countries, name) + } + sort.Strings(countries) + return append([]string{"client", "server"}, countries...), strings.Join(countries, ", ") +}() + +// requestFlags describe the request to send through the VCL. +type requestFlags struct { + path string + method string + headers []string + body string + clientFrom string + connection string + shield bool + noCluster bool + followRedirects bool + freshCache bool +} + +func (r *requestFlags) register(cmd *kingpin.CmdClause) { + cmd.Flag("path", "Request path, including any query string").Default("/").StringVar(&r.path) + cmd.Flag("method", "HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, PURGE, ...)").Default("GET").StringVar(&r.method) + cmd.Flag("header", "Request header as 'Name: value' (repeatable)").Short('H').PlaceHolder("HEADER").StringsVar(&r.headers) + cmd.Flag("body", "Request body; prefix with @ to read from a file").StringVar(&r.body) + cmd.Flag("client-from", "Where the client appears to connect from, for geolocation-dependent VCL: 'client' (your real vantage point), 'server', or a country: "+clientFromHelp).Default("client").HintOptions(clientFromValues...).StringVar(&r.clientFrom) + cmd.Flag("connection", "Protocol between client and edge: h2 (HTTP/2, default), h1 (HTTP/1.1 over TLS), or http (plain HTTP/1.1)").Default("h2").HintOptions(fiddle.ConnTypes...).StringVar(&r.connection) + cmd.Flag("shield", "Enable shielding (a second cache layer, as when a shield POP is configured)").BoolVar(&r.shield) + cmd.Flag("no-cluster", "Disable request clustering within the POP").BoolVar(&r.noCluster) + cmd.Flag("follow-redirects", "Follow 3xx responses instead of reporting them").BoolVar(&r.followRedirects) + cmd.Flag("fresh-cache", "Start from an empty cache instead of the cache state previous runs built up").BoolVar(&r.freshCache) +} + +func (r *requestFlags) toRequest() (fiddle.Request, error) { + req := fiddle.NewRequest() + req.Path = r.path + req.Method = strings.ToUpper(r.method) + req.EnableCluster = !r.noCluster + req.EnableShield = r.shield + req.FollowRedirects = r.followRedirects + req.UseFreshCache = r.freshCache + + var headers []string + for _, h := range r.headers { + if !strings.Contains(h, ":") { + return req, fmt.Errorf("header %q is not in 'Name: value' form", h) + } + headers = append(headers, strings.TrimSpace(h)) + } + req.Headers = strings.Join(headers, "\n") + + if strings.HasPrefix(r.body, "@") { + data, err := os.ReadFile(r.body[1:]) // #nosec G304 (user-supplied path) + if err != nil { + return req, err + } + req.Body = string(data) + } else { + req.Body = r.body + } + + from := strings.ToLower(r.clientFrom) + if wire, ok := clientFromCountries[from]; ok { + from = wire + } + if !slices.Contains(fiddle.SourceIPs, from) { + return req, fsterr.RemediationError{ + Inner: fmt.Errorf("unknown --client-from value %q", r.clientFrom), + Remediation: "Valid values: client, server, " + clientFromHelp + ".", + } + } + req.SourceIP = from + + if !slices.Contains(fiddle.ConnTypes, r.connection) { + return req, fmt.Errorf("unknown --connection value %q (valid: %s)", r.connection, strings.Join(fiddle.ConnTypes, ", ")) + } + req.ConnType = r.connection + return req, nil +} + +// RunCommand sends a request through VCL running on a real Fastly edge node and shows what happened. +type RunCommand struct { + argparser.Base + argparser.JSONOutput + + spec specFlags + request requestFlags + files []string + timeout time.Duration +} + +// NewRunCommand returns a usable command registered under the parent. +func NewRunCommand(parent argparser.Registerer, g *global.Data) *RunCommand { + c := RunCommand{ + Base: argparser.Base{ + Globals: g, + SuppressVerbose: true, + }, + } + c.CmdClause = parent.Command("run", "Run VCL on a real Fastly edge node by replaying a request through it (no service or API token needed)") + + // Optional. + c.CmdClause.Arg("file", "VCL files holding subroutine bodies, named after their subroutine (recv.vcl, fetch.vcl, ...)").StringsVar(&c.files) + c.spec.register(c.CmdClause) + c.request.register(c.CmdClause) + c.CmdClause.Flag("timeout", "How long to wait for the edge before giving up").Default("2m").DurationVar(&c.timeout) + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// Exec invokes the application logic for the command. +func (c *RunCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + vcl, sources, err := c.spec.buildVCL(c.files) + if err != nil { + return err + } + origins, err := c.spec.buildOrigins() + if err != nil { + return err + } + request, err := c.request.toRequest() + if err != nil { + return err + } + + ctx := context.Background() + client := c.spec.client(c.Globals) + state := loadSandboxState(c.Globals) + saved, err := publish(ctx, client, newSpec(origins, vcl, request), &state) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + if !saved.Valid { + diags := flattenDiagnostics(saved.LintStatus, sources) + if c.JSONOutput.Enabled { + if _, err := c.WriteJSON(out, compileResult{Diagnostics: diags}); err != nil { + return err + } + } else { + printDiagnostics(out, diags) + } + return errCompileFailed + } + + if !c.JSONOutput.Enabled { + printOrigins(out, origins, len(c.spec.origins) == 0) + } + + if state.CacheID == 0 || c.request.freshCache { + state.CacheID = freshCacheID() + saveSandboxState(state) + } + + result, err := client.Run(ctx, saved.ID, state.CacheID, fiddle.StreamOptions{ + WantFetches: 1, + MaxWait: c.timeout, + Syncing: func() { + if !c.JSONOutput.Enabled { + text.Info(out, "Deploying VCL to a test sandbox... (a first run can take a minute to reach the edge)") + } + }, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + fetch := pickFetch(result) + if fetch == nil { + return fmt.Errorf("the edge did not report a result in time; try again or raise --timeout") + } + + if c.JSONOutput.Enabled { + _, err := c.WriteJSON(out, newRunResult(request, result, fetch)) + return err + } + renderFetch(out, request, fetch) + if c.Globals.Verbose() { + renderTrace(out, result, fetch) + } + return nil +} + +// pickFetch returns the response the client ended up with, at the end of any redirects. +// If Fiddle stopped following redirects early, we show a response it got by following one, not the original response. +func pickFetch(r *fiddle.Result) *fiddle.ClientFetch { + keys := make([]string, 0, len(r.ClientFetch)) + for k, f := range r.ClientFetch { + if f.Complete { + keys = append(keys, k) + } + } + if len(keys) == 0 { + return nil + } + sort.Strings(keys) + var fallback *fiddle.ClientFetch + for _, k := range keys { + f := r.ClientFetch[k] + if f.Status < 300 || f.Status >= 400 { + return &f + } + if fallback == nil || f.IsRedirected && !fallback.IsRedirected { + fallback = &f + } + } + return fallback +} + +// parseRespHead splits a raw response head ("HTTP/2 200 OK\nname: value\n...") into header pairs, with lowercase names. +func parseRespHead(resp string) (headers [][2]string) { + lines := strings.Split(strings.ReplaceAll(resp, "\r\n", "\n"), "\n") + if len(lines) == 0 { + return nil + } + for _, line := range lines[1:] { + name, value, ok := strings.Cut(line, ":") + if !ok || name == "" { + continue + } + headers = append(headers, [2]string{strings.ToLower(strings.TrimSpace(name)), strings.TrimSpace(value)}) + } + return headers +} + +func headerValue(headers [][2]string, name string) string { + for _, h := range headers { + if h[0] == name { + return h[1] + } + } + return "" +} + +// renderFetch prints the response summary and body: +// +// GET /robots.txt → 200 (text/plain, 34 bytes, MISS) +func renderFetch(out io.Writer, req fiddle.Request, fetch *fiddle.ClientFetch) { + headers := parseRespHead(fetch.Resp) + var details []string + if ct := headerValue(headers, "content-type"); ct != "" { + if mt, _, ok := strings.Cut(ct, ";"); ok { + ct = strings.TrimSpace(mt) + } + details = append(details, ct) + } + details = append(details, fmt.Sprintf("%d bytes", fetch.BodyBytesReceived)) + if cache := headerValue(headers, "x-cache"); cache != "" { + details = append(details, lastCacheHop(cache)) + } + text.Output(out, "%s %s → %d (%s)", req.Method, req.Path, fetch.Status, strings.Join(details, ", ")) + + if fetch.BodyBytesReceived == 0 { + return + } + text.Break(out) + if !fetch.IsText { + text.Output(out, "(binary body, %d bytes)", fetch.BodyBytesReceived) + return + } + text.Output(out, "%s", text.SanitizeTerminalOutput(fetch.BodyPreview)) + if len(fetch.BodyPreview) < fetch.BodyBytesReceived { + text.Break(out) + text.Info(out, "Body truncated: showing the first %d of %d bytes.", len(fetch.BodyPreview), fetch.BodyBytesReceived) + } +} + +// renderTrace prints the full response head, the VCL subroutine trace, and any origin fetches. +func renderTrace(out io.Writer, result *fiddle.Result, fetch *fiddle.ClientFetch) { + if fetch != nil { + text.Break(out) + text.Output(out, "Response:") + for _, line := range strings.Split(strings.TrimSpace(fetch.Resp), "\n") { + text.Indent(out, 2, "%s", text.SanitizeTerminalOutput(line)) + } + } + if len(result.Events) > 0 { + text.Break(out) + text.Output(out, "VCL trace:") + for _, e := range result.Events { + if e.Type != "vcl-sub" { + continue + } + line := "vcl_" + e.FnName + if ret, ok := e.Attribs["return"].(string); ok && ret != "" { + line += " → return(" + ret + ")" + } + if e.Server.POP != "" { + line += " [POP " + e.Server.POP + "]" + } + text.Indent(out, 2, "%s", line) + } + } + if len(result.OriginFetch) > 0 { + text.Break(out) + text.Output(out, "Origin fetches:") + for _, f := range result.OriginFetch { + reqLine, _ := firstLine(f.Req) + text.Indent(out, 2, "%s → %d", reqLine, f.Status) + } + } +} + +func firstLine(s string) (string, bool) { + line, _, found := strings.Cut(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + return line, found +} + +// runResult is the --json output of run. +// Fiddle-specific fields like the POP are optional, since a local engine might not have them. +type runResult struct { + Provider string `json:"provider"` + Request runRequest `json:"request"` + Response runResponse `json:"response"` + Events []runEvent `json:"events"` + OriginFetches []runOriginFetch `json:"origin_fetches"` +} + +type runRequest struct { + Method string `json:"method"` + Path string `json:"path"` +} + +type runResponse struct { + Status int `json:"status"` + Headers map[string][]string `json:"headers"` + BodyPreview string `json:"body_preview"` + BodyBytes int `json:"body_bytes"` + BodyComplete bool `json:"body_complete"` +} + +type runEvent struct { + Subroutine string `json:"subroutine"` + Attributes map[string]any `json:"attributes,omitempty"` + POP string `json:"pop,omitempty"` +} + +type runOriginFetch struct { + Request string `json:"request"` + Status int `json:"status"` +} + +func newRunResult(req fiddle.Request, result *fiddle.Result, fetch *fiddle.ClientFetch) runResult { + headerMap := map[string][]string{} + for _, h := range parseRespHead(fetch.Resp) { + headerMap[h[0]] = append(headerMap[h[0]], h[1]) + } + var origins []runOriginFetch + for _, f := range result.OriginFetch { + reqLine, _ := firstLine(f.Req) + origins = append(origins, runOriginFetch{Request: reqLine, Status: f.Status}) + } + var events []runEvent + for _, e := range result.Events { + if e.Type != "vcl-sub" { + continue + } + events = append(events, runEvent{Subroutine: "vcl_" + e.FnName, Attributes: e.Attribs, POP: e.Server.POP}) + } + return runResult{ + Provider: "fiddle", + Request: runRequest{Method: req.Method, Path: req.Path}, + Response: runResponse{ + Status: fetch.Status, + Headers: headerMap, + BodyPreview: fetch.BodyPreview, + BodyBytes: fetch.BodyBytesReceived, + BodyComplete: len(fetch.BodyPreview) >= fetch.BodyBytesReceived, + }, + Events: events, + OriginFetches: origins, + } +} diff --git a/pkg/commands/vcl/run_internal_test.go b/pkg/commands/vcl/run_internal_test.go new file mode 100644 index 000000000..92d8d7956 --- /dev/null +++ b/pkg/commands/vcl/run_internal_test.go @@ -0,0 +1,22 @@ +package vcl + +import ( + "testing" + + "github.com/fastly/cli/pkg/fiddle" +) + +func TestPickFetchRedirectChain(t *testing.T) { + fetches := map[string]fiddle.ClientFetch{ + "a": {Status: 301, Complete: true}, + "b": {Status: 302, Complete: true, IsRedirected: true}, + } + if f := pickFetch(&fiddle.Result{ClientFetch: fetches}); f == nil || f.Status != 302 { + t.Fatalf("expected the redirect Fiddle stopped on, got %+v", f) + } + + fetches["c"] = fiddle.ClientFetch{Status: 200, Complete: true, IsRedirected: true} + if f := pickFetch(&fiddle.Result{ClientFetch: fetches}); f == nil || f.Status != 200 { + t.Fatalf("expected the end of the redirect chain, got %+v", f) + } +} diff --git a/pkg/commands/vcl/serve.go b/pkg/commands/vcl/serve.go new file mode 100644 index 000000000..30c9f9dd2 --- /dev/null +++ b/pkg/commands/vcl/serve.go @@ -0,0 +1,394 @@ +package vcl + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os/signal" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/bep/debounce" + "github.com/fsnotify/fsnotify" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/fiddle" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// maxBufferedBody caps how much of a request body the proxy keeps in memory. +const maxBufferedBody = 64 << 20 + +// ServeCommand runs a local HTTP server that sends every request through VCL deployed on a Fastly edge sandbox. +type ServeCommand struct { + argparser.Base + + spec specFlags + files []string + addr string + watch bool + timeout time.Duration +} + +// NewServeCommand returns a usable command registered under the parent. +func NewServeCommand(parent argparser.Registerer, g *global.Data) *ServeCommand { + c := ServeCommand{ + Base: argparser.Base{ + Globals: g, + SuppressVerbose: true, + }, + } + c.CmdClause = parent.Command("serve", "Run VCL as a local HTTP server backed by a real Fastly edge sandbox (no service or API token needed)") + + // Optional. + c.CmdClause.Arg("file", "VCL files holding subroutine bodies, named after their subroutine (recv.vcl, fetch.vcl, ...)").StringsVar(&c.files) + c.spec.register(c.CmdClause) + c.CmdClause.Flag("addr", "The local address to listen on").Default("127.0.0.1:7676").StringVar(&c.addr) + c.CmdClause.Flag("watch", "Redeploy the VCL when its files change").BoolVar(&c.watch) + c.CmdClause.Flag("timeout", "How long to wait for a deploy to reach the edge").Default("2m").DurationVar(&c.timeout) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ServeCommand) Exec(_ io.Reader, out io.Writer) error { + vcl, sources, err := c.spec.buildVCL(c.files) + if err != nil { + return err + } + origins, err := c.spec.buildOrigins() + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + s := &sandboxServer{ + client: c.spec.client(c.Globals), + origins: origins, + timeout: c.timeout, + out: out, + verbose: c.Globals.Verbose(), + } + + text.Info(out, "Deploying VCL to a test sandbox... (this can take a minute to reach the edge)") + if err := s.deploy(ctx, vcl, sources); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + text.Success(out, "VCL is live on the edge.") + printOrigins(out, origins, len(c.spec.origins) == 0) + + listener, err := net.Listen("tcp", c.addr) + if err != nil { + return err + } + text.Break(out) + text.Output(out, "Serving VCL on http://%s (Ctrl-C to stop)", listener.Addr()) + text.Break(out) + + if c.watch { + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + if err := s.watchSources(watchCtx, sources); err != nil { + return err + } + } + + server := &http.Server{ + Handler: s.handler(), + ReadHeaderTimeout: 30 * time.Second, + } + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(listener) + }() + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + text.Break(out) + text.Output(out, "Local server stopped.") + return nil + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +// sandboxServer is the state shared by the proxy and the deploy and watch code. +type sandboxServer struct { + client *fiddle.Client + origins []string + timeout time.Duration + out io.Writer + verbose bool + + fiddleID string + host atomic.Pointer[string] + + // deployMu makes sure only one deploy runs at a time, whether it's the first one, a --watch reload, or a resync after the sandbox expired. + deployMu sync.Mutex +} + +// deploy publishes the VCL and waits until the edge serves it. +// The spec has a single GET request, only used to find out when the VCL is live. +func (s *sandboxServer) deploy(ctx context.Context, vcl map[string]string, sources vclSources) error { + s.deployMu.Lock() + defer s.deployMu.Unlock() + spec := newSpec(s.origins, vcl, fiddle.NewRequest()) + var saved *fiddle.SavedFiddle + var err error + if s.fiddleID == "" { + // serve gets its own sandbox, so that running check or run in another terminal doesn't swap the VCL out from under it. + saved, err = s.client.Create(ctx, spec) + } else { + saved, err = s.client.Update(ctx, s.fiddleID, spec) + } + if err != nil { + return err + } + if !saved.Valid { + printDiagnostics(s.out, flattenDiagnostics(saved.LintStatus, sources)) + return errCompileFailed + } + s.fiddleID = saved.ID + return s.sync(ctx) +} + +// sync runs the sandbox once to learn its execution host, then points the proxy at it with a fresh cache. +func (s *sandboxServer) sync(ctx context.Context) error { + result, err := s.client.Run(ctx, s.fiddleID, freshCacheID(), fiddle.StreamOptions{ + WantFetches: 1, + MaxWait: s.timeout, + }) + if err != nil { + return err + } + execHost, err := fiddle.ParseExecHost(result.ExecHost) + if err != nil { + return err + } + host := execHost.Hostname(freshCacheID()) + s.host.Store(&host) + return nil +} + +// resync redeploys when the edge has dropped the sandbox, which can happen after a long idle time. +// When several requests notice at once, only one of them redeploys. +func (s *sandboxServer) resync(prevHost string) error { + s.deployMu.Lock() + defer s.deployMu.Unlock() + if current := s.host.Load(); current != nil && *current != prevHost { + return nil + } + text.Info(s.out, "The edge sandbox expired; redeploying...") + return s.sync(context.Background()) +} + +// handler proxies local requests to the execution host of the sandbox. +func (s *sandboxServer) handler() http.Handler { + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + host := *s.host.Load() + pr.Out.URL.Scheme = "https" + pr.Out.URL.Host = host + pr.Out.Host = host + pr.Out.Header.Del("Accept-Encoding") // keep bodies inspectable + }, + Transport: &resyncTransport{base: http.DefaultTransport, server: s}, + ModifyResponse: s.modifyResponse, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + text.Error(s.out, "%s %s: %s", r.Method, r.URL.Path, err) + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "sandbox proxy error: %s\n", err) + }, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Keep the body in memory, so the request can be sent again after a redeploy. + if r.Body != nil && r.ContentLength != 0 { + body, err := io.ReadAll(io.LimitReader(r.Body, maxBufferedBody+1)) + if err != nil { + http.Error(w, "failed reading request body", http.StatusBadRequest) + return + } + if len(body) > maxBufferedBody { + http.Error(w, "request body too large for the sandbox proxy", http.StatusRequestEntityTooLarge) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + r.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + } + proxy.ServeHTTP(w, r) + }) +} + +// modifyResponse logs the request, and moves the sandbox trace headers from the response to the terminal. +func (s *sandboxServer) modifyResponse(resp *http.Response) error { + trace := resp.Header.Get("Fastly-Fiddle-Log") + resp.Header.Del("Fastly-Fiddle-Log") + resp.Header.Del("Fastly-Fiddle-Starttime") + + details := "" + if cache := resp.Header.Get("X-Cache"); cache != "" { + details = " (" + lastCacheHop(cache) + ")" + } + req := resp.Request + text.Output(s.out, "%s %s → %d%s", req.Method, req.URL.Path, resp.StatusCode, details) + if s.verbose && trace != "" { + for _, line := range parseFiddleTrace(trace) { + text.Indent(s.out, 2, "%s", line) + } + } + return nil +} + +// parseFiddleTrace turns the Fastly-Fiddle-Log header into trace lines. +// The header is a comma-separated list, and the last field of each entry holds query-encoded attributes like fnName=recv&return=hash. +func parseFiddleTrace(header string) []string { + var lines []string + for _, entry := range strings.Split(header, ",") { + fields := strings.Fields(strings.TrimSpace(entry)) + if len(fields) < 2 { + continue + } + attribs, err := url.ParseQuery(fields[len(fields)-1]) + if err != nil { + continue + } + fn := attribs.Get("fnName") + if fn == "" { + continue + } + line := "vcl_" + fn + if ret := attribs.Get("return"); ret != "" { + line += " → return(" + ret + ")" + } + if state := attribs.Get("state"); state != "" { + line += " [" + state + "]" + } + lines = append(lines, line) + } + return lines +} + +// resyncTransport redeploys and retries once when the edge says it doesn't have the sandbox anymore. +type resyncTransport struct { + base http.RoundTripper + server *sandboxServer +} + +// sandboxGoneStatus is the status the edge returns for a fiddle it no longer has. +const sandboxGoneStatus = 982 + +func (t *resyncTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.URL.Host + resp, err := t.base.RoundTrip(req) + if err != nil || resp.StatusCode != sandboxGoneStatus { + return resp, err + } + _ = resp.Body.Close() + if err := t.server.resync(host); err != nil { + return nil, fmt.Errorf("the edge sandbox expired and redeploying failed: %w", err) + } + retry := req.Clone(req.Context()) + newHost := *t.server.host.Load() + retry.URL.Host = newHost + retry.Host = newHost + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return nil, err + } + retry.Body = body + } + return t.base.RoundTrip(retry) +} + +// watchSources redeploys when any of the VCL files change. +func (s *sandboxServer) watchSources(ctx context.Context, sources vclSources) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + // Watch directories, not files. + // Editors that save by replacing the file would silently break a file watch. + watched := map[string]bool{} + dirs := map[string]bool{} + for _, path := range sources { + abs, err := filepath.Abs(path) + if err != nil { + _ = watcher.Close() + return err + } + watched[abs] = true + dirs[filepath.Dir(abs)] = true + } + for dir := range dirs { + if err := watcher.Add(dir); err != nil { + _ = watcher.Close() + return err + } + } + reload := func() { + fresh, err := readSources(sources) + if err != nil { + text.Error(s.out, "reload failed: %s", err) + return + } + text.Info(s.out, "VCL changed; redeploying...") + started := time.Now() + if err := s.deploy(ctx, fresh, sources); err != nil { + if errors.Is(err, errCompileFailed) { + text.Warning(s.out, "Still serving the previous VCL.") + } else { + text.Error(s.out, "redeploy failed: %s", err) + } + return + } + text.Success(s.out, "VCL reloaded in %s.", time.Since(started).Round(time.Second)) + } + debounced := debounce.New(500 * time.Millisecond) + go func() { + defer watcher.Close() + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + if abs, err := filepath.Abs(event.Name); err != nil || !watched[abs] { + continue + } + debounced(reload) + case err, ok := <-watcher.Errors: + if !ok { + return + } + text.Error(s.out, "watch error: %s", err) + } + } + }() + return nil +} diff --git a/pkg/commands/vcl/vcl_test.go b/pkg/commands/vcl/vcl_test.go new file mode 100644 index 000000000..7ff81ac83 --- /dev/null +++ b/pkg/commands/vcl/vcl_test.go @@ -0,0 +1,162 @@ +package vcl_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/testutil" +) + +// fakeSandbox is a minimal in-memory stand-in for the Fiddle API. +func fakeSandbox(t *testing.T, valid bool) *httptest.Server { + t.Helper() + saved := `{"fiddle":{"id":"cafe0123"},"valid":true}` + if !valid { + saved = `{"fiddle":{"id":"cafe0123"},"valid":false,"lintStatus":{"recv":[{"level":"error","line":0,"str":"req.bogus","message":"Unknown variable"}]}}` + } + mux := http.NewServeMux() + mux.HandleFunc("POST /fiddle", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, saved) + }) + mux.HandleFunc("POST /fiddle/{id}/execute", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"sessionID":"feedbeef00"}`) + }) + mux.HandleFunc("GET /results/{sid}/stream", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `event: updateResult +data: {"execHost":"s-r-cafe0123v0-1-100.exec9.fiddle.fastly.dev","clientFetches":{"a":{"status":200,"complete":true,"resp":"HTTP/2 200 OK\ncontent-type: text/plain\nx-cache: MISS","bodyPreview":"hello","bodyBytesReceived":5,"isText":true}},"events":[{"type":"vcl-sub","fnName":"recv","attribs":{"return":"lookup"}}]} + +`) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func writeVCL(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// runVCL runs the scenarios for a vcl subcommand, each with its own sandbox state file in a temp dir. +func runVCL(t *testing.T, command string, scenarios []testutil.CLIScenario) { + t.Helper() + for i := range scenarios { + scenarios[i].ConfigPath = filepath.Join(t.TempDir(), config.FileName) + } + testutil.RunCLIScenarios(t, []string{"vcl", command}, scenarios) +} + +func TestVCLCheck(t *testing.T) { + dir := t.TempDir() + recv := writeVCL(t, dir, "recv.vcl", "set req.http.X = \"1\";\n") + wrapped := writeVCL(t, dir, "wrapped.recv.vcl", "sub vcl_recv {\n}\n") + mystery := writeVCL(t, dir, "mystery.vcl", "") + + valid := fakeSandbox(t, true) + invalid := fakeSandbox(t, false) + + runVCL(t, "check", []testutil.CLIScenario{ + { + Name: "validate no VCL given", + WantError: "no VCL given", + }, + { + Name: "validate slot inference failure", + Args: mystery, + WantError: "cannot tell which subroutine", + }, + { + Name: "reject files with sub wrappers", + Args: "--recv " + wrapped + " --sandbox-endpoint " + valid.URL, + WantError: "contains a subroutine declaration", + }, + { + Name: "reject too many origins", + Args: recv + " --origin https://a.example --origin https://b.example --origin https://c.example --origin https://d.example --origin https://e.example --origin https://f.example --sandbox-endpoint " + valid.URL, + WantError: "at most 5 origins", + }, + { + Name: "reject bad origin", + Args: recv + " --origin ftp://a.example --sandbox-endpoint " + valid.URL, + WantError: "must be a URL with an http or https scheme", + }, + { + Name: "compile success", + Args: recv + " --sandbox-endpoint " + valid.URL, + WantOutput: "VCL compiled cleanly.", + }, + { + Name: "compile failure prints diagnostics", + Args: recv + " --sandbox-endpoint " + invalid.URL, + WantError: "the VCL failed to compile", + WantOutput: recv + ":1: error: Unknown variable", + }, + { + Name: "compile failure in json", + Args: recv + " --json --sandbox-endpoint " + invalid.URL, + WantError: "the VCL failed to compile", + WantOutput: `"valid": false`, + }, + }) +} + +func TestVCLRun(t *testing.T) { + dir := t.TempDir() + recv := writeVCL(t, dir, "recv.vcl", "set req.http.X = \"1\";\n") + valid := fakeSandbox(t, true) + invalid := fakeSandbox(t, false) + + runVCL(t, "run", []testutil.CLIScenario{ + { + Name: "reject unknown client-from", + Args: recv + " --client-from atlantis --sandbox-endpoint " + valid.URL, + WantError: `unknown --client-from value "atlantis"`, + }, + { + Name: "reject unknown connection", + Args: recv + " --connection h3 --sandbox-endpoint " + valid.URL, + WantError: `unknown --connection value "h3"`, + }, + { + Name: "reject malformed header", + Args: recv + " --header nocolon --sandbox-endpoint " + valid.URL, + WantError: `header "nocolon" is not in 'Name: value' form`, + }, + { + Name: "replay a request", + Args: recv + " --path /greeting --sandbox-endpoint " + valid.URL, + WantOutputs: []string{"GET /greeting → 200 (text/plain, 5 bytes, MISS)", "hello"}, + }, + { + Name: "json output", + Args: recv + " --json --sandbox-endpoint " + valid.URL, + WantOutput: `"provider": "fiddle"`, + }, + { + Name: "compile failure in json", + Args: recv + " --json --sandbox-endpoint " + invalid.URL, + WantError: "the VCL failed to compile", + WantOutput: `"valid": false`, + DontWantOutput: "error: Unknown variable", + }, + }) +} + +func TestVCLServeValidation(t *testing.T) { + runVCL(t, "serve", []testutil.CLIScenario{ + { + Name: "validate no VCL given", + WantError: "no VCL given", + }, + }) +} diff --git a/pkg/fiddle/fiddle.go b/pkg/fiddle/fiddle.go new file mode 100644 index 000000000..670f7c93a --- /dev/null +++ b/pkg/fiddle/fiddle.go @@ -0,0 +1,214 @@ +// Package fiddle is a client for Fastly Fiddle (https://fiddle.fastly.dev), the sandbox that compiles and runs VCL on real Fastly edge nodes. +// +// The API isn't documented and was built for the Fiddle web UI, so it has a few traps this package hides: +// +// - VCL is sent as "vcl" but comes back as "src". +// - "valid" means "it compiles" after a create or update, but "it ran at least once" on a GET. +// - Results come over a server-sent events stream whose sessions expire quickly, and a new fiddle can take up to two minutes to reach the edge. +package fiddle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// DefaultEndpoint is the public Fiddle service. +const DefaultEndpoint = "https://fiddle.fastly.dev" + +// Subroutines lists the VCL subroutines a fiddle accepts, in the order a request goes through them. +var Subroutines = []string{ + "init", "recv", "hash", "hit", "miss", "pass", "fetch", "error", "deliver", "log", +} + +// MaxOrigins is how many origins a fiddle can have. +// The VCL sees them as backends F_origin_0 to F_origin_4. +const MaxOrigins = 5 + +// SourceIPs lists the values Request.SourceIP accepts: Fiddle's own "client" and "server" locations, or a country. +var SourceIPs = []string{"client", "server", "br", "cn", "de", "jp", "ru", "uk", "us", "za"} + +// ConnTypes lists the values Request.ConnType accepts. +var ConnTypes = []string{"http", "h1", "h2"} + +// Spec describes a fiddle to create or update. +type Spec struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + // The server rejects empty Origins or Requests, but fills in defaults when they're left out. + Origins []string `json:"origins,omitempty"` + VCL map[string]string `json:"vcl"` + Requests []Request `json:"requests,omitempty"` +} + +// Request is one request a fiddle sends through the VCL. +type Request struct { + Method string `json:"method"` + Path string `json:"path"` + Headers string `json:"headers"` + Body string `json:"body"` + EnableCluster bool `json:"enableCluster"` + EnableShield bool `json:"enableShield"` + UseFreshCache bool `json:"useFreshCache"` + FollowRedirects bool `json:"followRedirects"` + ConnType string `json:"connType"` + SourceIP string `json:"sourceIP"` + Delay int `json:"delay"` +} + +// NewRequest returns a Request with the same defaults as the Fiddle web UI. +func NewRequest() Request { + return Request{ + Method: "GET", + Path: "/", + EnableCluster: true, + ConnType: "h2", + SourceIP: "client", + } +} + +// Diagnostic is one compiler message about a subroutine. +type Diagnostic struct { + Level string `json:"level"` + Str string `json:"str"` + Line int `json:"line"` + Message string `json:"message"` +} + +// SavedFiddle is what Create and Update return. +type SavedFiddle struct { + ID string + SrcVersion int + // Valid reports whether the VCL compiled. + Valid bool + // LintStatus is keyed by subroutine name. + LintStatus map[string][]Diagnostic +} + +type envelope struct { + Fiddle struct { + ID string `json:"id"` + SrcVersion int `json:"srcVersion"` + } `json:"fiddle"` + Valid bool `json:"valid"` + LintStatus map[string][]Diagnostic `json:"lintStatus"` +} + +// Client talks to a Fiddle service. +type Client struct { + Endpoint string + HTTPClient interface { + Do(*http.Request) (*http.Response, error) + } + UserAgent string +} + +func (c *Client) do(ctx context.Context, method, path string, body any) ([]byte, error) { + var payload io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + payload = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(c.Endpoint, "/")+path, payload) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() // #nosec G307 + data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + // Error bodies are HTML or plain text, and only the first line is worth showing. + msg := strings.TrimSpace(string(data)) + if i := strings.IndexByte(msg, '\n'); i > 0 { + msg = msg[:i] + } + if len(msg) > 200 { + msg = msg[:200] + } + return nil, fmt.Errorf("sandbox returned %s: %s", resp.Status, msg) + } + return data, nil +} + +func (c *Client) save(ctx context.Context, method, path string, spec Spec) (*SavedFiddle, error) { + if spec.VCL == nil { + spec.VCL = map[string]string{} + } + data, err := c.do(ctx, method, path, spec) + if err != nil { + return nil, err + } + var env envelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, fmt.Errorf("unexpected response from sandbox: %w", err) + } + return &SavedFiddle{ + ID: env.Fiddle.ID, + SrcVersion: env.Fiddle.SrcVersion, + Valid: env.Valid, + LintStatus: env.LintStatus, + }, nil +} + +// Create publishes a new fiddle and returns its compile status. +func (c *Client) Create(ctx context.Context, spec Spec) (*SavedFiddle, error) { + return c.save(ctx, http.MethodPost, "/fiddle", spec) +} + +// Update replaces an existing fiddle. +// Send the whole spec, since missing subroutines get cleared. +// When only the requests change, the edge doesn't need to sync again, which makes this much faster than Create. +func (c *Client) Update(ctx context.Context, id string, spec Spec) (*SavedFiddle, error) { + return c.save(ctx, http.MethodPut, "/fiddle/"+id, spec) +} + +// Execute runs the fiddle's requests on the edge and returns a session ID to pass to WaitForResult. +// Runs with the same cacheID share a cache, so use a new one to start cold. +func (c *Client) Execute(ctx context.Context, id string, cacheID int) (string, error) { + data, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/fiddle/%s/execute?cacheID=%d", id, cacheID), nil) + if err != nil { + return "", err + } + var out struct { + SessionID string `json:"sessionID"` + } + if err := json.Unmarshal(data, &out); err != nil { + return "", fmt.Errorf("unexpected response from sandbox: %w", err) + } + if out.SessionID == "" { + return "", fmt.Errorf("sandbox did not return an execution session") + } + return out.SessionID, nil +} + +// sleep is like time.Sleep, but returns early when the context is done. +func sleep(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/pkg/fiddle/fiddle_test.go b/pkg/fiddle/fiddle_test.go new file mode 100644 index 000000000..4f4e19aa5 --- /dev/null +++ b/pkg/fiddle/fiddle_test.go @@ -0,0 +1,197 @@ +package fiddle_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/fastly/cli/pkg/fiddle" +) + +func TestParseExecHost(t *testing.T) { + h, err := fiddle.ParseExecHost("48254ae8ff-94b79f4b-1f31ceb6v0-12347-100.exec64.fiddle.fastly.dev") + if err != nil { + t.Fatal(err) + } + if h.FiddleID != "1f31ceb6" || h.Version != 0 || h.Pool != "exec64" { + t.Fatalf("unexpected parse: %+v", h) + } + want := "00fa571c00-00000000-1f31ceb6v0-42-100.exec64.fiddle.fastly.dev" + if got := h.Hostname(42); got != want { + t.Fatalf("got %q, want %q", got, want) + } + + for _, bad := range []string{ + "", + "a-b.exec64.fiddle.fastly.dev", + "a-b-cv1-d-e.example.com", + "a-b-noversion-d-e.exec64.fiddle.fastly.dev", + } { + if _, err := fiddle.ParseExecHost(bad); err == nil { + t.Errorf("expected error for %q", bad) + } + } +} + +// fakeFiddle is a minimal in-memory Fiddle API. +func fakeFiddle(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /fiddle", func(w http.ResponseWriter, r *http.Request) { + var spec map[string]any + if err := json.NewDecoder(r.Body).Decode(&spec); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if _, hasVCL := spec["vcl"]; !hasVCL { + http.Error(w, "missing vcl key", http.StatusInternalServerError) + return + } + fmt.Fprint(w, `{"fiddle":{"id":"cafe0123","srcVersion":0},"valid":true,"lintStatus":{"recv":[]}}`) + }) + mux.HandleFunc("PUT /fiddle/{id}", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"fiddle":{"id":"cafe0123","srcVersion":1},"valid":false,"lintStatus":{"recv":[{"level":"error","line":0,"message":"boom"}]}}`) + }) + mux.HandleFunc("POST /fiddle/{id}/execute", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"sessionID":"feedbeef00"}`) + }) + mux.HandleFunc("GET /results/{sid}/stream", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "event: waitingForSync\ndata: {}\n\n") + fmt.Fprint(w, "event: updateResult\ndata: {\"execHost\":\"s-r-cafe0123v0-1-100.exec9.fiddle.fastly.dev\",\n") + fmt.Fprint(w, "data: \"clientFetches\":{\"a\":{\"status\":200,\"complete\":true,\"resp\":\"HTTP/2 200 OK\\ncontent-type: text/plain\",\"bodyPreview\":\"hi\",\"bodyBytesReceived\":2,\"isText\":true}}}\n\n") + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +func testClient(server *httptest.Server) *fiddle.Client { + return &fiddle.Client{ + Endpoint: server.URL, + HTTPClient: server.Client(), + UserAgent: "test", + } +} + +func TestCreateAndUpdate(t *testing.T) { + client := testClient(fakeFiddle(t)) + saved, err := client.Create(context.Background(), fiddle.Spec{VCL: map[string]string{"recv": "error 601;"}}) + if err != nil { + t.Fatal(err) + } + if saved.ID != "cafe0123" || !saved.Valid { + t.Fatalf("unexpected create result: %+v", saved) + } + + saved, err = client.Update(context.Background(), saved.ID, fiddle.Spec{}) + if err != nil { + t.Fatal(err) + } + if saved.Valid { + t.Fatal("expected invalid VCL on update") + } + if len(saved.LintStatus["recv"]) != 1 || saved.LintStatus["recv"][0].Message != "boom" { + t.Fatalf("unexpected lint status: %+v", saved.LintStatus) + } +} + +func TestRunParsesStream(t *testing.T) { + client := testClient(fakeFiddle(t)) + result, err := client.Run(context.Background(), "cafe0123", 1, fiddle.StreamOptions{ + WantFetches: 1, + MaxWait: 5 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + if result.CompleteFetches() != 1 { + t.Fatalf("expected one complete fetch, got %+v", result) + } + fetch := result.ClientFetch["a"] + if fetch.Status != 200 || fetch.BodyPreview != "hi" { + t.Fatalf("unexpected fetch: %+v", fetch) + } + if !strings.Contains(result.ExecHost, "cafe0123v0") { + t.Fatalf("unexpected exec host: %q", result.ExecHost) + } +} + +func TestErrorBodiesAreTruncatedAndLegible(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Fiddle validation error in key 'origins': 42\nsecond line", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + client := testClient(server) + _, err := client.Create(context.Background(), fiddle.Spec{}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "Fiddle validation error") || strings.Contains(err.Error(), "second line") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWaitForResultReportsBrokenStream(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "event: updateResult\ndata: {\"clientFetches\":{\"a\":{}}}\n\n") + _ = http.NewResponseController(w).Flush() + panic(http.ErrAbortHandler) + })) + t.Cleanup(server.Close) + + result, err := testClient(server).WaitForResult(context.Background(), "feedbeef00", fiddle.StreamOptions{WantFetches: 1}) + if err == nil || result == nil { + t.Fatalf("expected the stream error along with the partial snapshot, got %+v, %v", result, err) + } +} + +func TestRunHonorsMaxWaitWhileExecuting(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := testClient(server).Run(ctx, "cafe0123", 1, fiddle.StreamOptions{MaxWait: 200 * time.Millisecond}) + if err == nil || !strings.Contains(err.Error(), "timed out after 200ms") { + t.Fatalf("expected a timeout error, got %v", err) + } +} + +func TestRunWaitsForRedirectedRequest(t *testing.T) { + var sessions atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/execute") { + fmt.Fprint(w, `{"sessionID":"feedbeef00"}`) + return + } + fmt.Fprint(w, `event: updateResult +data: {"requestCount":2,"clientFetches":{"a":{"status":301,"complete":true}}} + +`) + // The first session ends before the redirect is followed, like when an edge sync eats most of it. + if sessions.Add(1) == 1 { + return + } + fmt.Fprint(w, `event: updateResult +data: {"requestCount":2,"clientFetches":{"a":{"status":301,"complete":true},"b":{"status":200,"complete":true,"isRedirected":true}}} + +`) + _ = http.NewResponseController(w).Flush() + <-r.Context().Done() + })) + t.Cleanup(server.Close) + + result, err := testClient(server).Run(context.Background(), "cafe0123", 1, fiddle.StreamOptions{WantFetches: 1, MaxWait: 5 * time.Second}) + if err != nil || !result.ClientFetch["b"].IsRedirected { + t.Fatalf("expected the response the redirect led to, got %+v, %v", result, err) + } +} diff --git a/pkg/fiddle/host.go b/pkg/fiddle/host.go new file mode 100644 index 000000000..777cac622 --- /dev/null +++ b/pkg/fiddle/host.go @@ -0,0 +1,53 @@ +package fiddle + +import ( + "fmt" + "strings" +) + +// ExecHost is where a deployed fiddle runs on the edge. +// Requests sent straight to it go through the fiddle's VCL and get the full response back. +// +// Hostnames look like this: +// +// --v--..fiddle.fastly.dev +// +// The session, request, and cache ID parts can be anything. +// The pool can't be guessed: Fiddle picks it, and it only shows up in an execution result. +type ExecHost struct { + FiddleID string + Version int + // Pool is something like "exec64". + Pool string +} + +// ParseExecHost gets the fiddle ID, version, and pool out of Result.ExecHost. +func ParseExecHost(host string) (*ExecHost, error) { + labels := strings.SplitN(host, ".", 2) + if len(labels) != 2 || !strings.HasSuffix(labels[1], ".fiddle.fastly.dev") { + return nil, fmt.Errorf("unrecognized execution host %q", host) + } + pool := strings.TrimSuffix(labels[1], ".fiddle.fastly.dev") + // Count from the right, since the session and request parts may contain hyphens. + parts := strings.Split(labels[0], "-") + if len(parts) < 5 { + return nil, fmt.Errorf("unrecognized execution host %q", host) + } + id, ver, ok := strings.Cut(parts[len(parts)-3], "v") + if !ok || id == "" { + return nil, fmt.Errorf("unrecognized execution host %q", host) + } + var version int + if _, err := fmt.Sscanf(ver, "%d", &version); err != nil { + return nil, fmt.Errorf("unrecognized execution host %q", host) + } + return &ExecHost{FiddleID: id, Version: version, Pool: pool}, nil +} + +// Hostname returns the host to use for a cache bucket. +// Requests to the same bucket share a cache. +// The session and request parts are arbitrary constants. +func (h *ExecHost) Hostname(cacheID int) string { + return fmt.Sprintf("00fa571c00-00000000-%sv%d-%d-100.%s.fiddle.fastly.dev", + h.FiddleID, h.Version, cacheID, h.Pool) +} diff --git a/pkg/fiddle/stream.go b/pkg/fiddle/stream.go new file mode 100644 index 000000000..9d4abed3f --- /dev/null +++ b/pkg/fiddle/stream.go @@ -0,0 +1,216 @@ +package fiddle + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// ClientFetch is what the fiddle's test client saw for one request. +type ClientFetch struct { + // Req and Resp are the raw request and response heads: the first line, then one header per line. + Req string `json:"req"` + Resp string `json:"resp"` + Status int `json:"status"` + // BodyPreview is roughly the first kilobyte of the body. + BodyPreview string `json:"bodyPreview"` + BodyBytesReceived int `json:"bodyBytesReceived"` + IsText bool `json:"isText"` + Complete bool `json:"complete"` + // IsRedirected is set on the request made by following a redirect, not on the redirect itself. + IsRedirected bool `json:"isRedirected"` +} + +// OriginFetch is one request the edge made to an origin. +type OriginFetch struct { + Req string `json:"req"` + Resp string `json:"resp"` + Status int `json:"status"` +} + +// Event is one VCL subroutine call reported by the edge. +// The edge also sends timestamps, request IDs, and log lines, which aren't decoded. +type Event struct { + Type string `json:"type"` + FnName string `json:"fnName"` + Server struct { + POP string `json:"pop"` + NodeID string `json:"nodeID"` + } `json:"server"` + Attribs map[string]any `json:"attribs"` +} + +// Result is a snapshot of an execution. +// The stream can send several, each more complete than the last. +type Result struct { + ID string `json:"id"` + RequestCount int `json:"requestCount"` + ExecHost string `json:"execHost"` + ExecVersion int `json:"execVersion"` + ClientFetch map[string]ClientFetch `json:"clientFetches"` + OriginFetch map[string]OriginFetch `json:"originFetches"` + Events []Event `json:"events"` +} + +// CompleteFetches returns how many client fetches have finished. +func (r *Result) CompleteFetches() int { + n := 0 + for _, f := range r.ClientFetch { + if f.Complete { + n++ + } + } + return n +} + +// done is true once at least want fetches have finished and no request the server started is still running. +func (r *Result) done(want int) bool { + n := r.CompleteFetches() + return n >= want && n >= r.RequestCount +} + +// defaultMaxWait is long enough for a cold edge sync, without leaving a terminal hanging forever. +const defaultMaxWait = 2 * time.Minute + +// StreamOptions controls how long WaitForResult keeps the stream open. +type StreamOptions struct { + // WantFetches is the minimum number of finished client fetches to wait for. + // Requests the server is still running, like a redirect being followed, are waited for too. + WantFetches int + // MaxWait caps the whole wait, edge sync included. + MaxWait time.Duration + // Syncing, if set, is called once if the edge is still syncing after a few seconds, so callers can tell the user why it's slow. + Syncing func() +} + +// WaitForResult follows the result stream of a session until enough fetches are done, the server closes the stream, or MaxWait runs out. +// It returns the latest snapshot, which is nil if nothing arrived. +// If it times out or the stream breaks, the error comes with any partial snapshot received. +func (c *Client) WaitForResult(ctx context.Context, sessionID string, opts StreamOptions) (*Result, error) { + if opts.MaxWait <= 0 { + opts.MaxWait = defaultMaxWait + } + ctx, cancel := context.WithTimeout(ctx, opts.MaxWait) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSuffix(c.Endpoint, "/")+"/results/"+sessionID+"/stream", nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Accept", "text/event-stream") + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() // #nosec G307 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("result stream returned %s", resp.Status) + } + + var ( + latest *Result + event string + data strings.Builder + syncTicks int + ) + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 4<<20) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "event:"): + event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + if data.Len() > 0 { + data.WriteByte('\n') + } + data.WriteString(strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + case line == "": + // A blank line ends the event. + payload := data.String() + data.Reset() + switch event { + case "waitingForSync": + // The server sends one of these every second while syncing. + // A couple can show up even when the fiddle is already live, so wait for three before telling the caller. + syncTicks++ + if opts.Syncing != nil && syncTicks == 3 { + opts.Syncing() + opts.Syncing = nil + } + case "updateResult": + var r Result + if err := json.Unmarshal([]byte(payload), &r); err == nil { + latest = &r + if r.done(opts.WantFetches) { + return latest, nil + } + } + } + event = "" + } + } + if ctx.Err() != nil { + // Don't let a partial snapshot pass for a complete answer. + return latest, fmt.Errorf("timed out after %s waiting for the sandbox: %w", opts.MaxWait, ctx.Err()) + } + if err := scanner.Err(); err != nil { + return latest, err + } + // The server closed the stream, so this is all we'll get from this session. + // If it's nil or incomplete, the caller can retry. + return latest, nil +} + +// Run executes the fiddle and waits for the result. +// Right after a publish, the server often closes the stream before everything is done, so Run retries with new sessions. +func (c *Client) Run(ctx context.Context, id string, cacheID int, opts StreamOptions) (*Result, error) { + if opts.MaxWait <= 0 { + opts.MaxWait = defaultMaxWait + } + deadline := time.Now().Add(opts.MaxWait) + // MaxWait covers the whole run, including Execute and the pauses between retries. + runCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + var lastErr error + for attempt := 0; ; attempt++ { + remaining := time.Until(deadline) + if remaining <= 0 { + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("timed out after %s waiting for the sandbox", opts.MaxWait) + } + sessionID, err := c.Execute(runCtx, id, cacheID) + if err != nil { + if ctx.Err() == nil && runCtx.Err() != nil { + // Report running out of time as a regular timeout, not a raw context error. + continue + } + return nil, err + } + attemptOpts := opts + attemptOpts.MaxWait = remaining + result, err := c.WaitForResult(runCtx, sessionID, attemptOpts) + if err != nil { + lastErr = err + if ctx.Err() != nil { + return nil, err + } + } else if result != nil && result.done(opts.WantFetches) { + return result, nil + } + // Only the first attempt may report that the edge is syncing. + if opts.Syncing != nil { + opts.Syncing = nil + } + if err := sleep(runCtx, time.Duration(attempt+1)*time.Second); err != nil && ctx.Err() != nil { + return nil, err + } + } +}