From d47d45ce5a3b53cc6e03674f8f4cb6e1317e6d88 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:42:27 +0200 Subject: [PATCH 01/13] Prepare the addition of a VCL emulation command (first with fiddle) Just a skeleton for now --- pkg/fiddle/fiddle.go | 181 ++++++++++++++++++++++++++++++++++++++ pkg/fiddle/fiddle_test.go | 82 +++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 pkg/fiddle/fiddle.go create mode 100644 pkg/fiddle/fiddle_test.go diff --git a/pkg/fiddle/fiddle.go b/pkg/fiddle/fiddle.go new file mode 100644 index 000000000..d4bc389ee --- /dev/null +++ b/pkg/fiddle/fiddle.go @@ -0,0 +1,181 @@ +// 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. +package fiddle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// 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) +} diff --git a/pkg/fiddle/fiddle_test.go b/pkg/fiddle/fiddle_test.go new file mode 100644 index 000000000..5f6c73112 --- /dev/null +++ b/pkg/fiddle/fiddle_test.go @@ -0,0 +1,82 @@ +package fiddle_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/fastly/cli/pkg/fiddle" +) + +// 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"}]}}`) + }) + 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 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) + } +} From 35e3fcd84383e4c167d5992724f05fa58424c437 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:43:47 +0200 Subject: [PATCH 02/13] Move the vcl root command out of the alias package The vcl command is about to grow subcommands of its own that are not aliases of anything, so move the root command in a dedicated pkg/commands/vcl package. --- pkg/commands/commands.go | 11 ++++++----- pkg/commands/{alias => }/vcl/root.go | 0 2 files changed, 6 insertions(+), 5 deletions(-) rename pkg/commands/{alias => }/vcl/root.go (100%) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 9eb14af24..a397dee94 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,21 @@ 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) + 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) diff --git a/pkg/commands/alias/vcl/root.go b/pkg/commands/vcl/root.go similarity index 100% rename from pkg/commands/alias/vcl/root.go rename to pkg/commands/vcl/root.go From f920f60837fb86208b9305acdadbe86c03c59aab Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:47:03 +0200 Subject: [PATCH 03/13] Add a 'vcl check' command to compile VCL using fiddle The new subcommand sends subroutine bodies to Fiddle, and reports the diagnostics as compiler-style, editor-clickable lines (or as JSON with --json). Files can be passed either: - positionally, in which case the subroutine is inferred from the file name (recv.vcl, vcl_recv.vcl) - or through per-subroutine flags like --recv. The sandbox created on first use is remembered in a small state file next to the CLI config, so later invocations update it in place rather than creating a new one each time. --- pkg/app/run.go | 2 + pkg/app/run_test.go | 1 + pkg/commands/commands.go | 2 + pkg/commands/vcl/check.go | 73 ++++++++++ pkg/commands/vcl/common.go | 249 +++++++++++++++++++++++++++++++++++ pkg/commands/vcl/root.go | 2 +- pkg/commands/vcl/vcl_test.go | 101 ++++++++++++++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/vcl/check.go create mode 100644 pkg/commands/vcl/common.go create mode 100644 pkg/commands/vcl/vcl_test.go diff --git a/pkg/app/run.go b/pkg/app/run.go index 5dd84fd46..b5e95fc3f 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": + 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 a397dee94..7ec0af69e 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -1045,6 +1045,7 @@ func Define( // nolint:revive // function-length aliasServiceAuthList := aliasserviceauth.NewListCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthUpdate := aliasserviceauth.NewUpdateCommand(aliasServiceAuthRoot.CmdClause, data) vclRoot := vcltesting.NewRootCommand(app, data) + vclCheck := vcltesting.NewCheckCommand(vclRoot.CmdClause, data) aliasVclDescribe := aliasvcl.NewDescribeCommand(vclRoot.CmdClause, data) aliasVclConditionRoot := aliasvclcondition.NewRootCommand(vclRoot.CmdClause, data) aliasVclConditionCreate := aliasvclcondition.NewCreateCommand(aliasVclConditionRoot.CmdClause, data) @@ -2013,6 +2014,7 @@ func Define( // nolint:revive // function-length aliasServiceAuthDescribe, aliasServiceAuthList, aliasServiceAuthUpdate, + vclCheck, aliasVclDescribe, aliasVclConditionCreate, aliasVclConditionDelete, diff --git a/pkg/commands/vcl/check.go b/pkg/commands/vcl/check.go new file mode 100644 index 000000000..2ea6ac4e9 --- /dev/null +++ b/pkg/commands/vcl/check.go @@ -0,0 +1,73 @@ +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, struct { + Valid bool `json:"valid"` + Diagnostics []diagnostic `json:"diagnostics"` + }{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..af54e1957 --- /dev/null +++ b/pkg/commands/vcl/common.go @@ -0,0 +1,249 @@ +// 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" + "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 := map[string]string{} + for slot, path := range sources { + data, err := os.ReadFile(path) // #nosec G304 (user-supplied path) + if err != nil { + return nil, nil, err + } + body := string(data) + if m := subWrapper.FindStringSubmatch(body); m != nil { + return nil, 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, sources, 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, + } +} + +// 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"` +} + +// 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"` + + 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/vcl/root.go b/pkg/commands/vcl/root.go index bc4e3d148..047a749e9 100644 --- a/pkg/commands/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/vcl_test.go b/pkg/commands/vcl/vcl_test.go new file mode 100644 index 000000000..44b4c53e1 --- /dev/null +++ b/pkg/commands/vcl/vcl_test.go @@ -0,0 +1,101 @@ +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) + }) + 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`, + }, + }) +} From 1653ec4d07bf45cb7421e18dc81a24ca3459d086 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:47:37 +0200 Subject: [PATCH 04/13] Allow the fiddle client execute fiddles and stream results POST to the execute endpoint to get a session ID, then follow the SSE stream. --- pkg/fiddle/fiddle.go | 33 +++++++ pkg/fiddle/fiddle_test.go | 31 ++++++ pkg/fiddle/stream.go | 201 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 pkg/fiddle/stream.go diff --git a/pkg/fiddle/fiddle.go b/pkg/fiddle/fiddle.go index d4bc389ee..670f7c93a 100644 --- a/pkg/fiddle/fiddle.go +++ b/pkg/fiddle/fiddle.go @@ -4,6 +4,7 @@ // // - 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 ( @@ -14,6 +15,7 @@ import ( "io" "net/http" "strings" + "time" ) // DefaultEndpoint is the public Fiddle service. @@ -179,3 +181,34 @@ func (c *Client) Create(ctx context.Context, spec Spec) (*SavedFiddle, error) { 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 index 5f6c73112..ca8dfbb41 100644 --- a/pkg/fiddle/fiddle_test.go +++ b/pkg/fiddle/fiddle_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/fastly/cli/pkg/fiddle" ) @@ -31,6 +32,15 @@ func fakeFiddle(t *testing.T) *httptest.Server { 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 @@ -66,6 +76,27 @@ func TestCreateAndUpdate(t *testing.T) { } } +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) diff --git a/pkg/fiddle/stream.go b/pkg/fiddle/stream.go new file mode 100644 index 000000000..9d0b9361d --- /dev/null +++ b/pkg/fiddle/stream.go @@ -0,0 +1,201 @@ +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"` +} + +// 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 +} + +// 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 how many finished client fetches count as done. + // Zero means the first snapshot is enough. + 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. +// On timeout, the error comes with whatever partial snapshot was 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.CompleteFetches() >= 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 && latest == nil { + return nil, err + } + // The server closed the stream, so what we have is the answer. + // A nil result means this session produced nothing, and 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 anything 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) + 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(ctx, id, cacheID) + if err != nil { + return nil, err + } + attemptOpts := opts + attemptOpts.MaxWait = remaining + result, err := c.WaitForResult(ctx, sessionID, attemptOpts) + if err != nil { + lastErr = err + if ctx.Err() != nil { + return nil, err + } + } else if result != nil && result.CompleteFetches() >= 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(ctx, time.Duration(attempt+1)*time.Second); err != nil { + return nil, err + } + } +} From b093e5d115ec48bc648d837f788dad26f6864157 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:48:49 +0200 Subject: [PATCH 05/13] Add a "vcl run" command to replay a request "vcl run" publishes the VCL to the sandbox, replays a request and reports what the client saw: summary with the status, content type, body size and cache disposition, followed by the body. With --verbose it also prints the full response head, the VCL subroutine trace with return values, and any origin fetches. And we also support --json. To make things easy to remember, the flags are similar to the ones from curl (--path, --method, --header, --body), plus a few specific ones: --client-from, --connection, --shield,--no-cluster and --follow-redirects. --- pkg/app/run.go | 2 +- pkg/commands/commands.go | 2 + pkg/commands/vcl/common.go | 28 +++ pkg/commands/vcl/run.go | 408 +++++++++++++++++++++++++++++++++++ pkg/commands/vcl/vcl_test.go | 44 ++++ 5 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/vcl/run.go diff --git a/pkg/app/run.go b/pkg/app/run.go index b5e95fc3f..37199f0e8 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -767,7 +767,7 @@ 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": + case "vcl check", "vcl run": return false } commandName = strings.Split(commandName, " ")[0] diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 7ec0af69e..abf26ebb9 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -1046,6 +1046,7 @@ func Define( // nolint:revive // function-length aliasServiceAuthUpdate := aliasserviceauth.NewUpdateCommand(aliasServiceAuthRoot.CmdClause, data) vclRoot := vcltesting.NewRootCommand(app, data) vclCheck := vcltesting.NewCheckCommand(vclRoot.CmdClause, data) + vclRun := vcltesting.NewRunCommand(vclRoot.CmdClause, data) aliasVclDescribe := aliasvcl.NewDescribeCommand(vclRoot.CmdClause, data) aliasVclConditionRoot := aliasvclcondition.NewRootCommand(vclRoot.CmdClause, data) aliasVclConditionCreate := aliasvclcondition.NewCreateCommand(aliasVclConditionRoot.CmdClause, data) @@ -2015,6 +2016,7 @@ func Define( // nolint:revive // function-length aliasServiceAuthList, aliasServiceAuthUpdate, vclCheck, + vclRun, aliasVclDescribe, aliasVclConditionCreate, aliasVclConditionDelete, diff --git a/pkg/commands/vcl/common.go b/pkg/commands/vcl/common.go index af54e1957..a66ae8441 100644 --- a/pkg/commands/vcl/common.go +++ b/pkg/commands/vcl/common.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand/v2" "net/url" "os" "path/filepath" @@ -138,6 +139,30 @@ func (s *specFlags) client(g *global.Data) *fiddle.Client { } } +// 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"` @@ -196,6 +221,9 @@ var errCompileFailed = fsterr.RemediationError{ // 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 } diff --git a/pkg/commands/vcl/run.go b/pkg/commands/vcl/run.go new file mode 100644 index 000000000..51bac0b70 --- /dev/null +++ b/pkg/commands/vcl/run.go @@ -0,0 +1,408 @@ +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 { + printDiagnostics(out, flattenDiagnostics(saved.LintStatus, sources)) + 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 picks the fetch to show. +// When redirects produced several, the final non-3xx response wins, and sorting keeps the choice stable. +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 { + 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/vcl_test.go b/pkg/commands/vcl/vcl_test.go index 44b4c53e1..8961964da 100644 --- a/pkg/commands/vcl/vcl_test.go +++ b/pkg/commands/vcl/vcl_test.go @@ -23,6 +23,16 @@ func fakeSandbox(t *testing.T, valid bool) *httptest.Server { 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 @@ -99,3 +109,37 @@ func TestVCLCheck(t *testing.T) { }, }) } + +func TestVCLRun(t *testing.T) { + dir := t.TempDir() + recv := writeVCL(t, dir, "recv.vcl", "set req.http.X = \"1\";\n") + valid := fakeSandbox(t, true) + + 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"`, + }, + }) +} From a8c1a2abe28f64d3c2788ecdba6b4a522bb25e3e Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:49:18 +0200 Subject: [PATCH 06/13] Parse the execution hosts fiddles run on Fiddle returns a hostname. Requests sent straight to such a host run on fiddleand return the complete response, exactly like requests declared inside the fiddle. This is nice because we can use it as a regular HTTP backend. --- pkg/fiddle/fiddle_test.go | 25 ++++++++++++++++++ pkg/fiddle/host.go | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 pkg/fiddle/host.go diff --git a/pkg/fiddle/fiddle_test.go b/pkg/fiddle/fiddle_test.go index ca8dfbb41..5a0d8d50c 100644 --- a/pkg/fiddle/fiddle_test.go +++ b/pkg/fiddle/fiddle_test.go @@ -13,6 +13,31 @@ import ( "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() 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) +} From 04f62706b7540e44b1bf7adc53e3f09b0af90de7 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 9 Jul 2026 12:50:19 +0200 Subject: [PATCH 07/13] Add "vcl serve" to run VCL as a local HTTP server The new subcommand deploys a Fiddle sandbox and starts a reverse proxy on localhost (127.0.0.1:7676 by default) that forwards every request to fiddle. So that browsers can be used to directly use the sandboxed VCL service. Serve owns a dedicated sandbox instead of sharing the one check and run reuse, because a long-lived proxy must not have its VCL swapped out under it by an unrelated command. The proxy recognizes the tell-tale 982 status. And for convenience, with --watch, the source files are monitored and the VCL is redeployed on change. --- pkg/app/run.go | 2 +- pkg/commands/commands.go | 2 + pkg/commands/vcl/common.go | 16 +- pkg/commands/vcl/serve.go | 394 +++++++++++++++++++++++++++++++++++ pkg/commands/vcl/vcl_test.go | 9 + 5 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 pkg/commands/vcl/serve.go diff --git a/pkg/app/run.go b/pkg/app/run.go index 37199f0e8..9b961fc46 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -767,7 +767,7 @@ 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": + case "vcl check", "vcl run", "vcl serve": return false } commandName = strings.Split(commandName, " ")[0] diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index abf26ebb9..7f8530079 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -1047,6 +1047,7 @@ func Define( // nolint:revive // function-length 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) @@ -2017,6 +2018,7 @@ func Define( // nolint:revive // function-length aliasServiceAuthUpdate, vclCheck, vclRun, + vclServe, aliasVclDescribe, aliasVclConditionCreate, aliasVclConditionDelete, diff --git a/pkg/commands/vcl/common.go b/pkg/commands/vcl/common.go index a66ae8441..4e75b3a9d 100644 --- a/pkg/commands/vcl/common.go +++ b/pkg/commands/vcl/common.go @@ -96,22 +96,32 @@ func (s *specFlags) buildVCL(positional []string) (map[string]string, vclSources 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, nil, err + return nil, err } body := string(data) if m := subWrapper.FindStringSubmatch(body); m != nil { - return nil, nil, fsterr.RemediationError{ + 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, sources, nil + return vcl, nil } // buildOrigins checks the --origin flags, and falls back to the test origin when there are none. 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 index 8961964da..e75c5517c 100644 --- a/pkg/commands/vcl/vcl_test.go +++ b/pkg/commands/vcl/vcl_test.go @@ -143,3 +143,12 @@ func TestVCLRun(t *testing.T) { }, }) } + +func TestVCLServeValidation(t *testing.T) { + runVCL(t, "serve", []testutil.CLIScenario{ + { + Name: "validate no VCL given", + WantError: "no VCL given", + }, + }) +} From be753ad5621d334e465283b1ccd08cc27b909eff Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Sat, 7 Nov 2026 10:36:58 +0100 Subject: [PATCH 08/13] Update ChangeLog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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: From 10e0726618d2bd2e75086058abddbfa8cd13eb3c Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 11 Nov 2026 01:23:00 +0100 Subject: [PATCH 09/13] Address findings from copilot --- pkg/fiddle/fiddle_test.go | 28 ++++++++++++++++++++++++++++ pkg/fiddle/stream.go | 19 +++++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/pkg/fiddle/fiddle_test.go b/pkg/fiddle/fiddle_test.go index 5a0d8d50c..aa0cb0c6e 100644 --- a/pkg/fiddle/fiddle_test.go +++ b/pkg/fiddle/fiddle_test.go @@ -136,3 +136,31 @@ func TestErrorBodiesAreTruncatedAndLegible(t *testing.T) { 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) + } +} diff --git a/pkg/fiddle/stream.go b/pkg/fiddle/stream.go index 9d0b9361d..c83d8818a 100644 --- a/pkg/fiddle/stream.go +++ b/pkg/fiddle/stream.go @@ -81,7 +81,7 @@ type StreamOptions struct { // 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. -// On timeout, the error comes with whatever partial snapshot was received. +// 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 @@ -151,8 +151,8 @@ func (c *Client) WaitForResult(ctx context.Context, sessionID string, opts Strea // 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 && latest == nil { - return nil, err + if err := scanner.Err(); err != nil { + return latest, err } // The server closed the stream, so what we have is the answer. // A nil result means this session produced nothing, and the caller can retry. @@ -166,6 +166,9 @@ func (c *Client) Run(ctx context.Context, id string, cacheID int, opts StreamOpt 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) @@ -175,13 +178,17 @@ func (c *Client) Run(ctx context.Context, id string, cacheID int, opts StreamOpt } return nil, fmt.Errorf("timed out after %s waiting for the sandbox", opts.MaxWait) } - sessionID, err := c.Execute(ctx, id, cacheID) + 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(ctx, sessionID, attemptOpts) + result, err := c.WaitForResult(runCtx, sessionID, attemptOpts) if err != nil { lastErr = err if ctx.Err() != nil { @@ -194,7 +201,7 @@ func (c *Client) Run(ctx context.Context, id string, cacheID int, opts StreamOpt if opts.Syncing != nil { opts.Syncing = nil } - if err := sleep(ctx, time.Duration(attempt+1)*time.Second); err != nil { + if err := sleep(runCtx, time.Duration(attempt+1)*time.Second); err != nil && ctx.Err() != nil { return nil, err } } From 3c461c311cb2db53c9f7ddfdb564a6e2535127d5 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 11 Nov 2026 02:02:24 +0100 Subject: [PATCH 10/13] Prefer the redirected response in vcl run Looks like Fiddle only follows one redirect. So, if that lands on another redirect, show the second one instead of a random pick. --- pkg/commands/vcl/run.go | 5 +++-- pkg/commands/vcl/run_internal_test.go | 17 +++++++++++++++++ pkg/fiddle/stream.go | 2 ++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/vcl/run_internal_test.go diff --git a/pkg/commands/vcl/run.go b/pkg/commands/vcl/run.go index 51bac0b70..d15e555ef 100644 --- a/pkg/commands/vcl/run.go +++ b/pkg/commands/vcl/run.go @@ -217,7 +217,8 @@ func (c *RunCommand) Exec(_ io.Reader, out io.Writer) error { } // pickFetch picks the fetch to show. -// When redirects produced several, the final non-3xx response wins, and sorting keeps the choice stable. +// After a followed redirect, that's the response the redirect led to, even if it's another redirect. +// Otherwise, a non-3xx response wins, and sorting keeps the choice stable. func pickFetch(r *fiddle.Result) *fiddle.ClientFetch { keys := make([]string, 0, len(r.ClientFetch)) for k, f := range r.ClientFetch { @@ -232,7 +233,7 @@ func pickFetch(r *fiddle.Result) *fiddle.ClientFetch { var fallback *fiddle.ClientFetch for _, k := range keys { f := r.ClientFetch[k] - if f.Status < 300 || f.Status >= 400 { + if f.IsRedirected || f.Status < 300 || f.Status >= 400 { return &f } if fallback == nil { diff --git a/pkg/commands/vcl/run_internal_test.go b/pkg/commands/vcl/run_internal_test.go new file mode 100644 index 000000000..e87f413cc --- /dev/null +++ b/pkg/commands/vcl/run_internal_test.go @@ -0,0 +1,17 @@ +package vcl + +import ( + "testing" + + "github.com/fastly/cli/pkg/fiddle" +) + +func TestPickFetchPrefersRedirectedRequest(t *testing.T) { + result := &fiddle.Result{ClientFetch: map[string]fiddle.ClientFetch{ + "a": {Status: 301, Complete: true}, + "b": {Status: 302, Complete: true, IsRedirected: true}, + }} + if f := pickFetch(result); f == nil || f.Status != 302 { + t.Fatalf("expected the response the redirect led to, got %+v", f) + } +} diff --git a/pkg/fiddle/stream.go b/pkg/fiddle/stream.go index c83d8818a..079ff64d4 100644 --- a/pkg/fiddle/stream.go +++ b/pkg/fiddle/stream.go @@ -21,6 +21,8 @@ type ClientFetch struct { 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. From aa98a44606a43d64eee6307c7d1c7d4730ea5e95 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 11 Nov 2026 02:02:36 +0100 Subject: [PATCH 11/13] Wait for redirects to finish before showing a result We stopped at the first finished request, which could be the redirect itself. So wait until everything Fiddle started is done, and retry if a session ends too early. --- pkg/fiddle/fiddle_test.go | 31 +++++++++++++++++++++++++++++++ pkg/fiddle/stream.go | 20 +++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/pkg/fiddle/fiddle_test.go b/pkg/fiddle/fiddle_test.go index aa0cb0c6e..4f4e19aa5 100644 --- a/pkg/fiddle/fiddle_test.go +++ b/pkg/fiddle/fiddle_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -164,3 +165,33 @@ func TestRunHonorsMaxWaitWhileExecuting(t *testing.T) { 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/stream.go b/pkg/fiddle/stream.go index 079ff64d4..9d4abed3f 100644 --- a/pkg/fiddle/stream.go +++ b/pkg/fiddle/stream.go @@ -67,13 +67,19 @@ func (r *Result) CompleteFetches() int { 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 how many finished client fetches count as done. - // Zero means the first snapshot is enough. + // 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 @@ -141,7 +147,7 @@ func (c *Client) WaitForResult(ctx context.Context, sessionID string, opts Strea var r Result if err := json.Unmarshal([]byte(payload), &r); err == nil { latest = &r - if r.CompleteFetches() >= opts.WantFetches { + if r.done(opts.WantFetches) { return latest, nil } } @@ -156,13 +162,13 @@ func (c *Client) WaitForResult(ctx context.Context, sessionID string, opts Strea if err := scanner.Err(); err != nil { return latest, err } - // The server closed the stream, so what we have is the answer. - // A nil result means this session produced nothing, and the caller can retry. + // 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 anything is done, so Run retries with new sessions. +// 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 @@ -196,7 +202,7 @@ func (c *Client) Run(ctx context.Context, id string, cacheID int, opts StreamOpt if ctx.Err() != nil { return nil, err } - } else if result != nil && result.CompleteFetches() >= opts.WantFetches { + } else if result != nil && result.done(opts.WantFetches) { return result, nil } // Only the first attempt may report that the edge is syncing. From d4225ce79470f37afed4e1a60d5dd0b9fdeda8b8 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 11 Nov 2026 02:20:32 +0100 Subject: [PATCH 12/13] Print compile errors as JSON in vcl run --json When the VCL didn't compile, we printed plaintext diagnostics even with --json Spotted by Philipp, thanks! --- pkg/commands/vcl/check.go | 5 +---- pkg/commands/vcl/common.go | 6 ++++++ pkg/commands/vcl/run.go | 9 ++++++++- pkg/commands/vcl/vcl_test.go | 8 ++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/pkg/commands/vcl/check.go b/pkg/commands/vcl/check.go index 2ea6ac4e9..92f2806b4 100644 --- a/pkg/commands/vcl/check.go +++ b/pkg/commands/vcl/check.go @@ -54,10 +54,7 @@ func (c *CheckCommand) Exec(_ io.Reader, out io.Writer) error { } diags := flattenDiagnostics(saved.LintStatus, sources) - if ok, err := c.WriteJSON(out, struct { - Valid bool `json:"valid"` - Diagnostics []diagnostic `json:"diagnostics"` - }{saved.Valid, diags}); ok { + if ok, err := c.WriteJSON(out, compileResult{saved.Valid, diags}); ok { if err == nil && !saved.Valid { return errCompileFailed } diff --git a/pkg/commands/vcl/common.go b/pkg/commands/vcl/common.go index 4e75b3a9d..4439e0816 100644 --- a/pkg/commands/vcl/common.go +++ b/pkg/commands/vcl/common.go @@ -183,6 +183,12 @@ type diagnostic struct { 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 diff --git a/pkg/commands/vcl/run.go b/pkg/commands/vcl/run.go index d15e555ef..1882c9986 100644 --- a/pkg/commands/vcl/run.go +++ b/pkg/commands/vcl/run.go @@ -173,7 +173,14 @@ func (c *RunCommand) Exec(_ io.Reader, out io.Writer) error { return err } if !saved.Valid { - printDiagnostics(out, flattenDiagnostics(saved.LintStatus, sources)) + 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 } diff --git a/pkg/commands/vcl/vcl_test.go b/pkg/commands/vcl/vcl_test.go index e75c5517c..7ff81ac83 100644 --- a/pkg/commands/vcl/vcl_test.go +++ b/pkg/commands/vcl/vcl_test.go @@ -114,6 +114,7 @@ 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{ { @@ -141,6 +142,13 @@ func TestVCLRun(t *testing.T) { 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", + }, }) } From 882f759f6c9324e5088bed3fe45893e3bf8bd063 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Fri, 13 Nov 2026 19:31:11 +0100 Subject: [PATCH 13/13] Address philipp's feedback re. the redirect chain After 301 -> 302 -> 200, both the 302 and the 200 are marked as redirected, and we could end up showing the 302. Now any response that isn't a redirect is used. --- pkg/commands/vcl/run.go | 9 ++++----- pkg/commands/vcl/run_internal_test.go | 15 ++++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/commands/vcl/run.go b/pkg/commands/vcl/run.go index 1882c9986..52e38505a 100644 --- a/pkg/commands/vcl/run.go +++ b/pkg/commands/vcl/run.go @@ -223,9 +223,8 @@ func (c *RunCommand) Exec(_ io.Reader, out io.Writer) error { return nil } -// pickFetch picks the fetch to show. -// After a followed redirect, that's the response the redirect led to, even if it's another redirect. -// Otherwise, a non-3xx response wins, and sorting keeps the choice stable. +// 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 { @@ -240,10 +239,10 @@ func pickFetch(r *fiddle.Result) *fiddle.ClientFetch { var fallback *fiddle.ClientFetch for _, k := range keys { f := r.ClientFetch[k] - if f.IsRedirected || f.Status < 300 || f.Status >= 400 { + if f.Status < 300 || f.Status >= 400 { return &f } - if fallback == nil { + if fallback == nil || f.IsRedirected && !fallback.IsRedirected { fallback = &f } } diff --git a/pkg/commands/vcl/run_internal_test.go b/pkg/commands/vcl/run_internal_test.go index e87f413cc..92d8d7956 100644 --- a/pkg/commands/vcl/run_internal_test.go +++ b/pkg/commands/vcl/run_internal_test.go @@ -6,12 +6,17 @@ import ( "github.com/fastly/cli/pkg/fiddle" ) -func TestPickFetchPrefersRedirectedRequest(t *testing.T) { - result := &fiddle.Result{ClientFetch: map[string]fiddle.ClientFetch{ +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(result); f == nil || f.Status != 302 { - t.Fatalf("expected the response the redirect led to, got %+v", f) + } + 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) } }