diff --git a/__tests__/bin/vip-edge-workers-get.js b/__tests__/bin/vip-edge-workers-get.js index ed397ab46..b321ee40f 100644 --- a/__tests__/bin/vip-edge-workers-get.js +++ b/__tests__/bin/vip-edge-workers-get.js @@ -87,6 +87,31 @@ describe( 'edgeWorkersGetCommand()', () => { expect( console.log ).toHaveBeenCalledWith( 'export default {};' ); } ); + it.each( [ + [ + 'newlines and tabs', + '// café\n\nexport function run(): void {\n\treturn;\n}\n', + '// café\n\nexport function run(): void {\n\treturn;\n}\n', + ], + [ 'Windows line endings', 'export {};\r\n\t// comment\r\n', 'export {};\n\t// comment\n' ], + [ + 'terminal controls', + '\u0000\u0007\b\v\f\r\u001b[2J\u007f\u0085\u009b31m', + String.raw`\u0000\u0007\u0008\u000b\u000c\u000d\u001b[2J\u007f\u0085\u009b31m`, + ], + [ + 'literal escape sequences', + String.raw`// literal \u000a and \t`, + String.raw`// literal \u000a and \t`, + ], + ] )( 'renders stored source with safe %s', async ( _name, source, expected ) => { + edgeWorkersApi.getEdgeWorker.mockResolvedValue( { ...worker, source } ); + + await edgeWorkersGetCommand( [ 'headers' ], { ...opts, source: true } ); + + expect( console.log ).toHaveBeenLastCalledWith( expected ); + } ); + it( 'reports when explicitly requested source was not stored', async () => { edgeWorkersApi.getEdgeWorker.mockResolvedValue( { ...worker, source: null } ); diff --git a/__tests__/bin/vip-edge-workers-init.js b/__tests__/bin/vip-edge-workers-init.js index de17d51e9..b9575043a 100644 --- a/__tests__/bin/vip-edge-workers-init.js +++ b/__tests__/bin/vip-edge-workers-init.js @@ -50,6 +50,7 @@ describe( 'edgeWorkersInitCommand()', () => { expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'Created a new assemblyscript edge-workers project' ) ); + expect( console.log ).toHaveBeenCalledWith( ' vip edge-workers new my-worker' ); const scaffoldOrder = scaffoldProject.mock.invocationCallOrder[ 0 ]; const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 ); expect( scaffoldOrder ).toBeLessThan( successOrder ); diff --git a/__tests__/lib/edge-workers/project.js b/__tests__/lib/edge-workers/project.js index f37ef2465..cb84fc7a9 100644 --- a/__tests__/lib/edge-workers/project.js +++ b/__tests__/lib/edge-workers/project.js @@ -189,6 +189,19 @@ describe( 'edge-workers project', () => { } ); describe( 'worker source and artifacts', () => { + it.each( [ + [ [ 0xff, 0xff ], '\ufffd\ufffd' ], + [ [ 0xe1, 0x80 ], '\ufffd' ], + [ [ 0xe1, 0x80, 0xff ], '\ufffd\ufffd' ], + ] )( 'replaces malformed UTF-8 source sequences %j', ( bytes, expected ) => { + const worker = { + dir: tmp, + manifest: { name: 'headers', entry: 'source.ts' }, + }; + fs.writeFileSync( path.join( tmp, 'source.ts' ), Buffer.from( bytes ) ); + expect( readWorkerSource( worker ) ).toBe( expected ); + } ); + it( 'throws when worker source cannot be read', () => { const worker = { dir: path.join( tmp, 'worker' ), diff --git a/cmd/vip-next/commands/edge_workers.go b/cmd/vip-next/commands/edge_workers.go new file mode 100644 index 000000000..8755a9ffa --- /dev/null +++ b/cmd/vip-next/commands/edge_workers.go @@ -0,0 +1,197 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/edgeworkers" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/term" +) + +type edgeWorkersDeps struct { + Getwd func() (string, error) + Service edgeworkers.Service + Confirm func(*cobra.Command, string, bool) (bool, error) + IsInteractive func(*cobra.Command) bool + StdoutTTY func(*cobra.Command) bool +} + +func NewEdgeWorkersCmd() *cobra.Command { + return newEdgeWorkersCmd(edgeWorkersDeps{Service: edgeworkers.Service{API: edgeworkers.APIClient{Client: GetConfig().GQLClient}, Builder: edgeworkers.Compiler{}}}) +} +func newEdgeWorkersCmd(deps edgeWorkersDeps) *cobra.Command { + if deps.Getwd == nil { + deps.Getwd = os.Getwd + } + if deps.Confirm == nil { + deps.Confirm = appctx.Confirm + } + if deps.IsInteractive == nil { + deps.IsInteractive = appctx.IsInteractive + } + if deps.StdoutTTY == nil { + deps.StdoutTTY = func(c *cobra.Command) bool { + f, ok := c.OutOrStdout().(*os.File) + return ok && term.IsTerminal(int(f.Fd())) + } + } + c := &cobra.Command{Use: "edge-workers", Short: "Manage edge workers for an environment.", RunE: func(c *cobra.Command, _ []string) error { return c.Help() }} + // NUL distinguishes a bare optional flag from the literal string "true". + // It is parser state, not a display default; do not let pflag emit it. + defaultHelp := c.HelpFunc() + c.SetHelpFunc(func(cmd *cobra.Command, args []string) { + var marked []*pflag.Flag + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.NoOptDefVal == edgeWorkersBareValue { + marked = append(marked, flag) + flag.NoOptDefVal = "" + } + }) + defer func() { + for _, flag := range marked { + flag.NoOptDefVal = edgeWorkersBareValue + } + }() + defaultHelp(cmd, args) + }) + c.AddCommand(newEdgeWorkersInitCmd(deps), newEdgeWorkersNewCmd(deps), newEdgeWorkersBuildCmd(deps), newEdgeWorkersListCmd(deps), newEdgeWorkersGetCmd(deps), newEdgeWorkersValidateCmd(deps), newEdgeWorkersDeployCmd(deps), newEdgeWorkersLifecycleCmd(deps, "enable"), newEdgeWorkersLifecycleCmd(deps, "disable"), newEdgeWorkersLifecycleCmd(deps, "delete")) + return c +} +func edgeWorkersTrack(c *cobra.Command, event string, props map[string]any) { + p := map[string]any{} + for k, v := range props { + p[k] = v + } + if ae := appctx.FromContext(c.Context()); ae != nil { + p["app_id"] = ae.App.ID + p["env_id"] = ae.Env.ID + } + trackEvent("edge_workers_"+event, p) +} +func edgeName(args []string) string { + if len(args) > 0 { + return args[0] + } + return "" +} +func edgeNameProps(args []string) map[string]any { + p := map[string]any{} + if len(args) > 0 { + p["name"] = args[0] + } + return p +} +func edgeBool(c *cobra.Command, name string) bool { v, _ := c.Flags().GetBool(name); return v } +func edgeError(c *cobra.Command, action string, args []string, err error) error { + var apiErr *edgeworkers.APIError + if errors.As(err, &apiErr) { + for _, message := range apiErr.Messages { + fmt.Fprintf(c.ErrOrStderr(), "Error: %s\n", edgeworkers.EscapeTerminalText(message)) + } + } + p := edgeNameProps(args) + p["error"] = action + "_failed" + edgeWorkersTrack(c, action+"_command_error", p) + var partial *edgeworkers.ApplyError + if errors.As(err, &partial) { + return errors.New(edgeworkers.PartialFailureMessage(partial)) + } + message := edgeworkers.EscapeTerminalText(err.Error()) + if action == "init" || action == "new" || action == "build" { + return errors.New(message) + } + noun := "edge worker" + if action == "list" { + noun = "edge workers" + } + return fmt.Errorf("Failed to %s %s: %s", action, noun, message) +} +func edgeProject(deps edgeWorkersDeps, path *edgeWorkersStringFlag) (string, error) { + cwd, err := deps.Getwd() + if err != nil { + return "", err + } + var explicit *string + if len(path.Values) > 0 { + v, ok := edgeWorkersOptionValue(path).(string) + if !ok { + return "", errors.New("The --path flag requires a path to the edge-workers project.") + } + explicit = &v + } + return edgeworkers.ResolveProjectDir(cwd, explicit) +} +func edgeSelect(deps edgeWorkersDeps, c *cobra.Command, args []string, path *edgeWorkersStringFlag, action string) (string, []edgeworkers.LocalWorker, error) { + name := edgeName(args) + all := edgeBool(c, "all") + if name != "" && all { + return "", nil, errors.New("Supply either a worker name or --all, not both.") + } + dir, err := edgeProject(deps, path) + if err != nil { + return "", nil, err + } + if name != "" { + w, err := edgeworkers.FindWorker(dir, name) + if err != nil { + return "", nil, err + } + return dir, []edgeworkers.LocalWorker{w}, nil + } + if !all && action != "build" { + return "", nil, fmt.Errorf("Please supply a worker name to %s, or pass `--all`.", action) + } + workers, err := edgeworkers.DiscoverWorkers(dir) + if err != nil { + return "", nil, err + } + if len(workers) == 0 { + message := "No workers found in this project." + if action == "build" { + message += " Create one with `vip edge-workers new`." + } + return "", nil, errors.New(message) + } + return dir, workers, nil +} +func edgeRemote(c *cobra.Command, required bool, handler appctx.RunFunc) *cobra.Command { + addAppEnvFlags(c) + mw := []appctx.Middleware{appctx.WithAppContext(GetConfig().AppCtxConfig), appctx.WithEnvContext()} + if required { + mw = append(mw, appctx.WithRequiredArgs(1)) + } + return appctx.Build(c, mw...).WithRun(handler) +} +func edgeConfirm(deps edgeWorkersDeps, c *cobra.Command) func(string) (bool, error) { + return func(message string) (bool, error) { + ok, err := deps.Confirm(c, message, false) + if errors.Is(err, appctx.ErrNonInteractive) { + return false, nil + } + return ok, err + } +} +func edgeProduction(deps edgeWorkersDeps, c *cobra.Command, action string, names []string, enable bool) error { + ae := appctx.FromContext(c.Context()) + return edgeworkers.ConfirmProduction(edgeworkers.ProductionConfirmation{Action: action, AppName: ae.App.Name, EnvType: ae.Env.Type, WorkerNames: names, EnableAfterDeploy: enable, SkipConfirmation: edgeBool(c, "skip-confirmation"), NonInteractive: !deps.IsInteractive(c) || !deps.StdoutTTY(c)}, edgeConfirm(deps, c)) +} +func edgeArtifact(deps edgeWorkersDeps, c *cobra.Command, dir string, w edgeworkers.LocalWorker) (edgeworkers.Artifact, error) { + if edgeBool(c, "skip-build") { + return edgeworkers.ReadPrebuilt(dir, w) + } + if deps.Service.Builder == nil { + return edgeworkers.Artifact{}, errors.New("No worker compiler configured.") + } + return deps.Service.Builder.Build(c.Context(), dir, w) +} +func edgeAbs(cwd, target string) string { + if filepath.IsAbs(target) { + return filepath.Clean(target) + } + return filepath.Join(cwd, target) +} diff --git a/cmd/vip-next/commands/edge_workers_flags.go b/cmd/vip-next/commands/edge_workers_flags.go new file mode 100644 index 000000000..4e59c2a3f --- /dev/null +++ b/cmd/vip-next/commands/edge_workers_flags.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + "github.com/Automattic/vip/internal/nodeflags" + "github.com/spf13/cobra" + "strings" +) + +const edgeWorkersBareValue = "\x00" + +type edgeWorkersStringFlag struct{ Values []string } + +func (v *edgeWorkersStringFlag) String() string { + if len(v.Values) == 0 { + return "" + } + return v.Values[len(v.Values)-1] +} +func (*edgeWorkersStringFlag) Type() string { return "string" } +func (v *edgeWorkersStringFlag) Set(s string) error { v.Values = append(v.Values, s); return nil } +func edgeWorkersOptionValue(v *edgeWorkersStringFlag) any { + if len(v.Values) == 0 { + return nil + } + values := make([]any, len(v.Values)) + for i, s := range v.Values { + values[i] = s + if s == edgeWorkersBareValue { + values[i] = true + } + } + if len(values) == 1 { + return values[0] + } + return values +} +func edgeOptionText(value any) string { + if values, ok := value.([]any); ok { + parts := make([]string, len(values)) + for i, v := range values { + parts[i] = fmt.Sprint(v) + } + return strings.Join(parts, ",") + } + return fmt.Sprint(value) +} +func edgeStringFlag(c *cobra.Command, name, short, description string) *edgeWorkersStringFlag { + v := &edgeWorkersStringFlag{} + c.Flags().VarP(v, name, short, description) + nodeflags.MarkOptionalValue(c, edgeWorkersBareValue, name) + return v +} +func edgePathFlag(c *cobra.Command) *edgeWorkersStringFlag { + return edgeStringFlag(c, "path", "p", "Path to the edge-workers project. Defaults to auto-discovery.") +} diff --git a/cmd/vip-next/commands/edge_workers_local.go b/cmd/vip-next/commands/edge_workers_local.go new file mode 100644 index 000000000..f5667621d --- /dev/null +++ b/cmd/vip-next/commands/edge_workers_local.go @@ -0,0 +1,127 @@ +package commands + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/edgeworkers" + "github.com/spf13/cobra" +) + +func newEdgeWorkersInitCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "init [path]", Short: "Scaffold a new edge-workers project.", Example: " vip-next edge-workers init\n vip-next edge-workers init ./infra/edge --type=assemblyscript"} + kind := edgeStringFlag(c, "type", "t", `The worker toolchain to scaffold. Accepts assemblyscript. Default is "assemblyscript".`) + c.RunE = func(c *cobra.Command, args []string) error { + value := edgeWorkersOptionValue(kind) + kindString, isString := value.(string) + if value == nil || isString && kindString == "" { + value = "assemblyscript" + } + kindText := edgeOptionText(value) + props := map[string]any{"type": value} + edgeWorkersTrack(c, "init_command_execute", props) + if text, ok := value.(string); !ok || text != "assemblyscript" { + edgeWorkersTrack(c, "init_command_error", map[string]any{"type": value, "error": "Unsupported type"}) + return fmt.Errorf("Unsupported type \"%s\". Supported types: assemblyscript.", edgeworkers.EscapeTerminalText(kindText)) + } + cwd, err := deps.Getwd() + if err != nil { + return edgeError(c, "init", args, err) + } + target := edgeName(args) + if target == "" { + target = edgeworkers.ConventionalDir + } + dir := edgeAbs(cwd, target) + if err := edgeworkers.ScaffoldProject(dir, kindText); err != nil { + edgeWorkersTrack(c, "init_command_error", map[string]any{"type": value, "error": "init_failed"}) + return errors.New(edgeworkers.EscapeTerminalText(err.Error())) + } + edgeWorkersTrack(c, "init_command_success", props) + _, err = fmt.Fprintf(c.OutOrStdout(), "✓ Created a new %s edge-workers project in %s\n\nNext steps:\n cd %s\n npm install\n vip-next edge-workers new my-worker\n", kindText, edgeworkers.EscapeTerminalText(dir), edgeworkers.EscapeTerminalText(target)) + return err + } + return c +} +func newEdgeWorkersNewCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "new ", Short: "Add a new worker to an edge-workers project.", Example: " vip-next edge-workers new add-security-headers\n vip-next edge-workers new my-worker --path ./infra/edge\n vip-next edge-workers new api-auth --location starts_with:/api/"} + path := edgePathFlag(c) + location := edgeStringFlag(c, "location", "l", `Only run the worker on matching request paths, as ":". Operators: `+edgeworkers.LocationOperators+".") + return appctx.Build(c, appctx.WithRequiredArgs(1)).WithRun(func(c *cobra.Command, args []string) error { + name := edgeName(args) + edgeWorkersTrack(c, "new_command_execute", edgeNameProps(args)) + run := func() error { + if err := edgeworkers.ValidateWorkerName(name, "worker name"); err != nil { + return err + } + var loc *edgeworkers.Location + if value := edgeWorkersOptionValue(location); value != nil { + raw, ok := value.(string) + if !ok { + return errors.New("The --location flag requires a value in the form \":\" (e.g. \"starts_with:/api/\"). Operators: " + edgeworkers.LocationOperators + ".") + } + parsed, err := edgeworkers.ParseLocationOption(raw) + if err != nil { + return err + } + loc = &parsed + } + dir, err := edgeProject(deps, path) + if err != nil { + return err + } + descriptor, err := edgeworkers.ReadProjectDescriptor(dir) + if err != nil { + return err + } + if err := edgeworkers.ScaffoldWorker(dir, name, loc); err != nil { + return err + } + edgeWorkersTrack(c, "new_command_success", map[string]any{"name": name, "type": descriptor.Type}) + fmt.Fprintf(c.OutOrStdout(), "✓ Created worker \"%s\" in %s\n", edgeworkers.EscapeTerminalText(name), edgeworkers.EscapeTerminalText(filepath.Join(dir, edgeworkers.WorkersDir, name))) + if loc == nil { + fmt.Fprintln(c.OutOrStdout(), "Scope: all requests. Set location in worker.json before deployment to narrow it.") + } else { + fmt.Fprintf(c.OutOrStdout(), "Scope: %s \"%s\".\n", edgeworkers.EscapeTerminalText(loc.Operator), edgeworkers.EscapeTerminalText(loc.Value)) + } + _, err = fmt.Fprintf(c.OutOrStdout(), "\nEdit the worker, then deploy it with:\n vip-next @my-site.develop edge-workers deploy %s\n", edgeworkers.EscapeTerminalText(name)) + return err + } + if err := run(); err != nil { + return edgeError(c, "new", args, err) + } + return nil + }) +} +func newEdgeWorkersBuildCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "build [name]", Short: "Compile worker(s) to WebAssembly locally.", Example: " vip-next edge-workers build\n vip-next edge-workers build my-worker"} + path := edgePathFlag(c) + c.Flags().BoolP("all", "a", false, "Compile every worker in the project.") + c.RunE = func(c *cobra.Command, args []string) error { + props := edgeNameProps(args) + props["all"] = edgeBool(c, "all") + edgeWorkersTrack(c, "build_command_execute", props) + dir, workers, err := edgeSelect(deps, c, args, path, "build") + if err != nil { + return edgeError(c, "build", args, err) + } + for _, w := range workers { + art, err := edgeArtifact(deps, c, dir, w) + if err != nil { + return edgeError(c, "build", args, err) + } + relative, err := filepath.Rel(dir, art.Path) + if err != nil { + return edgeError(c, "build", args, err) + } + if _, err := fmt.Fprintf(c.OutOrStdout(), "✓ Built \"%s\" → %s (%d bytes)\n", edgeworkers.EscapeTerminalText(w.Manifest.Name), edgeworkers.EscapeTerminalText(relative), art.SizeBytes); err != nil { + return err + } + } + edgeWorkersTrack(c, "build_command_success", map[string]any{"count": len(workers)}) + return nil + } + return c +} diff --git a/cmd/vip-next/commands/edge_workers_mutation.go b/cmd/vip-next/commands/edge_workers_mutation.go new file mode 100644 index 000000000..6e59b480c --- /dev/null +++ b/cmd/vip-next/commands/edge_workers_mutation.go @@ -0,0 +1,130 @@ +package commands + +import ( + "fmt" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/edgeworkers" + "github.com/Automattic/vip/internal/output" + "github.com/spf13/cobra" +) + +func newEdgeWorkersDeployCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "deploy [name]", Short: "Compile and deploy a worker to an environment.", Example: " vip-next @example-app.develop edge-workers deploy my-worker\n vip-next @example-app.develop edge-workers deploy --all\n vip-next @example-app.develop edge-workers deploy my-worker --skip-build"} + path := edgePathFlag(c) + c.Flags().Bool("all", false, "Deploy every worker in the project.") + c.Flags().BoolP("skip-build", "s", false, "Deploy a previously compiled artifact without recompiling.") + c.Flags().Bool("skip-validate", false, "Skip server-side dry-run validation before uploading.") + c.Flags().Bool("skip-source", false, "Do not store source on create; preserve stored source on update.") + c.Flags().Bool("enable", false, "Enable each deployed worker after a successful upload.") + c.Flags().Bool("skip-confirmation", false, "Skip the production deployment confirmation.") + return edgeRemote(c, false, func(c *cobra.Command, args []string) error { + props := edgeNameProps(args) + props["all"] = edgeBool(c, "all") + edgeWorkersTrack(c, "deploy_command_execute", props) + run := func() error { + dir, workers, err := edgeSelect(deps, c, args, path, "deploy") + if err != nil { + return err + } + ae := appctx.FromContext(c.Context()) + enable := edgeBool(c, "enable") + plan, err := deps.Service.PreparePlan(c.Context(), edgeworkers.PlanOptions{AppID: ae.App.ID, EnvID: ae.Env.ID, ProjectDir: dir, Workers: workers, SkipBuild: edgeBool(c, "skip-build"), SkipValidate: edgeBool(c, "skip-validate"), SkipSource: edgeBool(c, "skip-source"), Enable: enable}) + if err != nil { + return err + } + if _, err := fmt.Fprintln(c.OutOrStdout(), output.TableString(edgeworkers.PlanRows(plan))); err != nil { + return err + } + names := make([]string, len(plan)) + for i, item := range plan { + names[i] = item.Worker.Manifest.Name + } + if err := edgeProduction(deps, c, "deploy", names, enable); err != nil { + return err + } + inactive := []string{} + activeCount := 0 + err = deps.Service.ApplyPlan(c.Context(), ae.Env.ID, plan, func(item edgeworkers.PlanItem, result edgeworkers.Worker) error { + if !enable && item.Action == "create" && !result.Active { + inactive = append(inactive, item.Worker.Manifest.Name) + } + if result.Active { + activeCount++ + } + _, err := fmt.Fprintln(c.OutOrStdout(), edgeworkers.AppliedMessage(item, result)) + return err + }) + if err != nil { + return err + } + if len(inactive) > 0 { + if _, err := fmt.Fprintln(c.OutOrStdout(), edgeworkers.InactiveCreateGuidance(inactive)); err != nil { + return err + } + } + edgeWorkersTrack(c, "deploy_command_success", map[string]any{"count": len(plan), "enable": enable, "activeCount": activeCount}) + return nil + } + if err := run(); err != nil { + return edgeError(c, "deploy", args, err) + } + return nil + }) +} +func newEdgeWorkersLifecycleCmd(deps edgeWorkersDeps, action string) *cobra.Command { + verb := map[string]string{"enable": "Enable", "disable": "Disable", "delete": "Permanently delete"}[action] + c := &cobra.Command{Use: action + " ", Short: verb + " a deployed edge worker.", Example: " vip-next @example-app.production edge-workers " + action + " my-worker"} + if action == "enable" { + c.Flags().BoolP("skip-confirmation", "s", false, "Skip the production enable confirmation.") + } + if action == "delete" { + c.Flags().BoolP("force", "f", false, "Skip confirmation.") + } + return edgeRemote(c, true, func(c *cobra.Command, args []string) error { + props := edgeNameProps(args) + edgeWorkersTrack(c, action+"_command_execute", props) + name := edgeName(args) + run := func() error { + ae := appctx.FromContext(c.Context()) + workers, err := deps.Service.API.List(c.Context(), ae.App.ID, ae.Env.ID) + if err != nil { + return err + } + var worker *edgeworkers.Worker + for i := range workers { + if workers[i].Name == name { + worker = &workers[i] + break + } + } + if worker == nil { + return fmt.Errorf("No edge worker named \"%s\" is deployed to this environment.", name) + } + if action == "enable" { + if err := edgeProduction(deps, c, "enable", []string{worker.Name}, false); err != nil { + return err + } + } + if action == "delete" { + if err := edgeworkers.ConfirmDeletion(ae.App.Name, ae.Env.Type, worker.Name, edgeBool(c, "force"), edgeConfirm(deps, c)); err != nil { + return err + } + err = deps.Service.API.Delete(c.Context(), ae.Env.ID, worker.ID) + } else { + _, err = deps.Service.API.SetActive(c.Context(), ae.Env.ID, worker.ID, action == "enable") + } + if err != nil { + return err + } + edgeWorkersTrack(c, action+"_command_success", props) + past := map[string]string{"enable": "Enabled", "disable": "Disabled", "delete": "Deleted"}[action] + _, err = fmt.Fprintf(c.OutOrStdout(), "✓ %s edge worker \"%s\".\n", past, edgeworkers.EscapeTerminalText(name)) + return err + } + if err := run(); err != nil { + return edgeError(c, action, args, err) + } + return nil + }) +} diff --git a/cmd/vip-next/commands/edge_workers_read.go b/cmd/vip-next/commands/edge_workers_read.go new file mode 100644 index 000000000..efc3642df --- /dev/null +++ b/cmd/vip-next/commands/edge_workers_read.go @@ -0,0 +1,103 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/edgeworkers" + "github.com/spf13/cobra" +) + +func newEdgeWorkersListCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "list", Short: "List the edge workers deployed to an environment.", Example: " vip-next @example-app.production edge-workers list"} + addFormatFlagWithShort(c) + return buildAppEnvRenderableCmd(c, "table", []string{"table", "csv", "json"}, func(c *cobra.Command, args []string) (any, error) { + edgeWorkersTrack(c, "list_command_execute", nil) + ae := appctx.FromContext(c.Context()) + workers, err := deps.Service.API.List(c.Context(), ae.App.ID, ae.Env.ID) + if err != nil { + return nil, edgeError(c, "list", nil, err) + } + edgeWorkersTrack(c, "list_command_success", map[string]any{"count": len(workers)}) + format := string(appctx.FormatFromContext(c.Context())) + if len(workers) == 0 && format != "json" { + fmt.Fprintln(c.OutOrStdout(), "No edge workers are deployed to this environment.") + // Node console.logs the empty formatted result as well. + fmt.Fprintln(c.OutOrStdout()) + } + return edgeworkers.ListRows(workers, format), nil + }) +} +func newEdgeWorkersGetCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "get ", Short: "Retrieve details for a single deployed edge worker.", Example: " vip-next @example-app.production edge-workers get my-worker\n vip-next @example-app.production edge-workers get my-worker --source"} + c.Flags().BoolP("source", "s", false, "Print the stored source code for the worker.") + return edgeRemote(c, true, func(c *cobra.Command, args []string) error { + props := edgeNameProps(args) + edgeWorkersTrack(c, "get_command_execute", props) + ae := appctx.FromContext(c.Context()) + source := edgeBool(c, "source") + name := edgeName(args) + w, err := deps.Service.API.Get(c.Context(), ae.App.ID, ae.Env.ID, name, source) + if err != nil { + return edgeError(c, "get", args, err) + } + if w == nil { + edgeWorkersTrack(c, "get_command_error", map[string]any{"name": name, "error": "Not found"}) + return fmt.Errorf("No edge worker named \"%s\" is deployed to this environment.", edgeworkers.EscapeTerminalText(name)) + } + edgeWorkersTrack(c, "get_command_success", props) + _, err = fmt.Fprintln(c.OutOrStdout(), edgeworkers.DetailText(*w, source)) + return err + }) +} +func newEdgeWorkersValidateCmd(deps edgeWorkersDeps) *cobra.Command { + c := &cobra.Command{Use: "validate [name]", Short: "Validate worker(s) against an environment without deploying.", Example: " vip-next @example-app.develop edge-workers validate my-worker\n vip-next @example-app.develop edge-workers validate --all\n vip-next @example-app.develop edge-workers validate my-worker --skip-build"} + path := edgePathFlag(c) + c.Flags().Bool("all", false, "Validate every worker in the project.") + c.Flags().BoolP("skip-build", "s", false, "Validate a previously compiled artifact without recompiling.") + return edgeRemote(c, false, func(c *cobra.Command, args []string) error { + props := edgeNameProps(args) + props["all"] = edgeBool(c, "all") + edgeWorkersTrack(c, "validate_command_execute", props) + dir, workers, err := edgeSelect(deps, c, args, path, "validate") + if err != nil { + return edgeError(c, "validate", args, err) + } + ae := appctx.FromContext(c.Context()) + invalid := 0 + for _, w := range workers { + art, err := edgeArtifact(deps, c, dir, w) + if err != nil { + return edgeError(c, "validate", args, err) + } + result, err := deps.Service.API.Validate(c.Context(), ae.Env.ID, art.Base64) + if err != nil { + return edgeError(c, "validate", args, err) + } + if !result.Valid { + invalid++ + details := edgeJoin(result.Errors, "; ", "unknown error") + fmt.Fprintf(c.OutOrStdout(), "✕ \"%s\" is invalid: %s\n", edgeworkers.EscapeTerminalText(w.Manifest.Name), details) + } else { + fmt.Fprintf(c.OutOrStdout(), "✓ \"%s\" is valid (phases: %s)\n", edgeworkers.EscapeTerminalText(w.Manifest.Name), edgeJoin(result.Phases, ", ", "none")) + } + } + if invalid > 0 { + return edgeError(c, "validate", args, fmt.Errorf("%d worker(s) failed validation.", invalid)) + } + edgeWorkersTrack(c, "validate_command_success", map[string]any{"count": len(workers), "invalid": invalid}) + return nil + }) +} +func edgeJoin(values []string, sep, fallback string) string { + parts := make([]string, len(values)) + for i, s := range values { + parts[i] = edgeworkers.EscapeTerminalText(s) + } + s := strings.Join(parts, sep) + if s == "" { + return fallback + } + return s +} diff --git a/cmd/vip-next/commands/edge_workers_test.go b/cmd/vip-next/commands/edge_workers_test.go new file mode 100644 index 000000000..c3eb098f7 --- /dev/null +++ b/cmd/vip-next/commands/edge_workers_test.go @@ -0,0 +1,272 @@ +package commands + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/edgeworkers" + "github.com/Automattic/vip/internal/nodeflags" + "github.com/Automattic/vip/internal/telemetry" + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" +) + +type edgeEvent struct { + name string + props map[string]any +} +type edgeEventRecorder struct{ events []edgeEvent } + +func (r *edgeEventRecorder) TrackEvent(name string, props map[string]any) error { + r.events = append(r.events, edgeEvent{name, props}) + return nil +} +func TestEdgeWorkersTelemetry(t *testing.T) { + edgeCommandConfig(t) + for _, key := range []string{"DO_NOT_TRACK", "GO_ENV", "NODE_ENV"} { + t.Setenv(key, "") + } + recorder := &edgeEventRecorder{} + cfg := GetConfig() + cfg.Tracker = &telemetry.Tracker{Clients: []telemetry.Client{recorder}} + SetConfig(cfg) + dir := edgeTestProject(t) + api := &edgeCommandAPI{valid: true} + deps := edgeWorkersDeps{Getwd: func() (string, error) { return dir, nil }, Service: edgeworkers.Service{API: api}} + if _, err := runEdgeCommand(t, deps, "deploy", "headers", "--app=42", "--env=develop", "--skip-build"); err != nil { + t.Fatal(err) + } + want := []edgeEvent{{"edge_workers_deploy_command_execute", map[string]any{"app_id": int64(42), "env_id": int64(8), "name": "headers", "all": false}}, {"edge_workers_deploy_command_success", map[string]any{"app_id": int64(42), "env_id": int64(8), "count": 1, "enable": false, "activeCount": 0}}} + if !reflect.DeepEqual(recorder.events, want) { + t.Fatalf("events %#v", recorder.events) + } + recorder.events = nil + if _, err := runEdgeCommand(t, deps, "deploy", "headers", "--all", "--app=42", "--env=develop"); err == nil { + t.Fatal("accepted conflict") + } + if len(recorder.events) != 2 || recorder.events[1].name != "edge_workers_deploy_command_error" || recorder.events[1].props["error"] != "deploy_failed" { + t.Fatalf("events %#v", recorder.events) + } +} + +type edgeCommandAPI struct { + edgeworkers.API + calls []string + workers []edgeworkers.Worker + valid bool + enableError error +} + +func (a *edgeCommandAPI) List(context.Context, int64, int64) ([]edgeworkers.Worker, error) { + a.calls = append(a.calls, "list") + return a.workers, nil +} +func (a *edgeCommandAPI) Get(_ context.Context, _, _ int64, name string, source bool) (*edgeworkers.Worker, error) { + a.calls = append(a.calls, fmt.Sprintf("get:%s:%v", name, source)) + for _, w := range a.workers { + if w.Name == name { + return &w, nil + } + } + return nil, nil +} +func (a *edgeCommandAPI) Validate(context.Context, int64, string) (edgeworkers.ValidationResult, error) { + a.calls = append(a.calls, "validate") + return edgeworkers.ValidationResult{Valid: a.valid, Errors: []string{"bad wasm"}, Phases: []string{"client_response"}}, nil +} +func (a *edgeCommandAPI) Create(_ context.Context, _ int64, in edgeworkers.WriteInput) (edgeworkers.Worker, error) { + a.calls = append(a.calls, "create:"+in.Name) + return edgeworkers.Worker{ID: 9, Name: in.Name, Phases: []string{"client_response"}}, nil +} +func (a *edgeCommandAPI) SetActive(_ context.Context, _, _ int64, active bool) (edgeworkers.Worker, error) { + a.calls = append(a.calls, fmt.Sprintf("active:%v", active)) + return edgeworkers.Worker{ID: 9, Name: "headers", Active: active}, a.enableError +} +func (a *edgeCommandAPI) Delete(context.Context, int64, int64) error { + a.calls = append(a.calls, "delete") + return nil +} + +type edgeCommandBuilder struct { + calls []string + err error +} + +func (b *edgeCommandBuilder) Build(_ context.Context, dir string, w edgeworkers.LocalWorker) (edgeworkers.Artifact, error) { + b.calls = append(b.calls, w.Manifest.Name) + return edgeworkers.Artifact{Path: filepath.Join(dir, "build", w.Manifest.Name+".wasm"), Base64: "AGFzbQEAAAA=", SizeBytes: 8}, b.err +} + +func edgeCommandConfig(t *testing.T) { + t.Helper() + old := GetConfig() + t.Cleanup(func() { SetConfig(old) }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"app":{"id":42,"name":"example-app","environments":[{"id":7,"appId":7,"name":"production","type":"production"},{"id":8,"appId":8,"name":"develop","type":"develop"}]}}}`) + })) + t.Cleanup(server.Close) + client := graphql.NewClient(server.URL, server.Client()) + SetConfig(Config{GQLClient: client, AppCtxConfig: appctx.AppContextConfig{Client: client}}) +} +func runEdgeCommand(t *testing.T, deps edgeWorkersDeps, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "vip-next", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("non-interactive", false, "Disable prompts") + root.AddCommand(newEdgeWorkersCmd(deps)) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(nodeflags.NormalizeOptionalValues(root, append([]string{"edge-workers"}, args...))) + err := root.Execute() + return out.String(), err +} +func edgeTestProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := edgeworkers.ScaffoldProject(dir, "assemblyscript"); err != nil { + t.Fatal(err) + } + if err := edgeworkers.ScaffoldWorker(dir, "headers", nil); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "build"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "build", "headers.wasm"), []byte{0, 97, 115, 109, 1, 0, 0, 0}, 0600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestEdgeWorkersLocalCommands(t *testing.T) { + edgeCommandConfig(t) + dir := t.TempDir() + builder := &edgeCommandBuilder{} + deps := edgeWorkersDeps{Getwd: func() (string, error) { return dir, nil }, Service: edgeworkers.Service{Builder: builder}} + out, err := runEdgeCommand(t, deps, "init") + if err != nil || !strings.Contains(out, "✓ Created a new assemblyscript") { + t.Fatalf("%s %v", out, err) + } + if !strings.Contains(out, "\n vip-next edge-workers new my-worker\n") { + t.Fatalf("init guidance invokes another runtime: %s", out) + } + out, err = runEdgeCommand(t, deps, "new", "headers", "-l=starts_with:/api/") + if err != nil || !strings.Contains(out, `Scope: starts_with "/api/".`) { + t.Fatalf("%s %v", out, err) + } + if !strings.Contains(out, "\n vip-next @my-site.develop edge-workers deploy headers\n") { + t.Fatalf("new guidance invokes another runtime: %s", out) + } + out, err = runEdgeCommand(t, deps, "build", "headers") + if err != nil || out != "✓ Built \"headers\" → build/headers.wasm (8 bytes)\n" { + t.Fatalf("%q %v", out, err) + } + for _, args := range [][]string{{"new", "bad", "--location"}, {"new", "bad", "--path"}, {"new", "bad", "--path=x", "--path=y"}, {"build", "headers", "--all"}, {"init", "--type"}} { + if _, err := runEdgeCommand(t, deps, args...); err == nil { + t.Fatalf("accepted %v", args) + } + } + if _, err := os.Stat(filepath.Join(dir, "edge-workers/workers/bad")); !os.IsNotExist(err) { + t.Fatal("invalid input left worker") + } +} + +func TestEdgeWorkersProductionAndLifecycle(t *testing.T) { + edgeCommandConfig(t) + dir := edgeTestProject(t) + for _, skip := range []bool{false, true} { + api := &edgeCommandAPI{valid: true} + deps := edgeWorkersDeps{Getwd: func() (string, error) { return dir, nil }, Service: edgeworkers.Service{API: api}, IsInteractive: func(*cobra.Command) bool { return true }, StdoutTTY: func(*cobra.Command) bool { return false }, Confirm: func(*cobra.Command, string, bool) (bool, error) { + t.Fatal("prompted redirected stdout") + return false, nil + }} + args := []string{"deploy", "headers", "--app=42", "--env=production", "--skip-build"} + if skip { + args = append(args, "--skip-confirmation") + } + out, err := runEdgeCommand(t, deps, args...) + if skip { + if err != nil || !strings.Contains(out, "created \"headers\"; inactive") || !strings.Contains(out, "Review created inactive") { + t.Fatalf("%s %v", out, err) + } + } else if err == nil || !strings.Contains(err.Error(), "Refusing to deploy") { + t.Fatalf("%s %v", out, err) + } + if strings.Contains(strings.Join(api.calls, ","), "create:") != skip { + t.Fatalf("calls %v", api.calls) + } + } + for _, action := range []string{"enable", "disable", "delete"} { + api := &edgeCommandAPI{workers: []edgeworkers.Worker{{ID: 9, Name: "headers"}}} + prompts := 0 + deps := edgeWorkersDeps{Service: edgeworkers.Service{API: api}, IsInteractive: func(*cobra.Command) bool { return true }, StdoutTTY: func(*cobra.Command) bool { return true }, Confirm: func(*cobra.Command, string, bool) (bool, error) { prompts++; return false, nil }} + _, err := runEdgeCommand(t, deps, action, "headers", "--app=42", "--env=production") + if action == "disable" { + if err != nil || prompts != 0 { + t.Fatalf("disable %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), "cancelled") || len(api.calls) != 1 { + t.Fatalf("%s %v %v", action, err, api.calls) + } + } + api := &edgeCommandAPI{valid: true, enableError: errors.New("timeout")} + deps := edgeWorkersDeps{Getwd: func() (string, error) { return dir, nil }, Service: edgeworkers.Service{API: api}} + out, err := runEdgeCommand(t, deps, "deploy", "headers", "--app=42", "--env=develop", "--skip-build", "--enable") + if err == nil || !strings.Contains(err.Error(), "Final active state is unknown") || strings.Contains(out, "✓ created") { + t.Fatalf("%s %v", out, err) + } +} + +func TestEdgeWorkersReadsAndValidation(t *testing.T) { + edgeCommandConfig(t) + dir := edgeTestProject(t) + empty := "" + api := &edgeCommandAPI{workers: []edgeworkers.Worker{{ID: 9, Name: "headers", Source: &empty}}, valid: false} + deps := edgeWorkersDeps{Getwd: func() (string, error) { return dir, nil }, Service: edgeworkers.Service{API: api}} + out, err := runEdgeCommand(t, deps, "list", "--app=42", "--env=develop", "--format=json") + if err != nil || !strings.Contains(out, `"id": 9`) || strings.Contains(out, "source") { + t.Fatalf("%s %v", out, err) + } + out, err = runEdgeCommand(t, deps, "get", "headers", "--app=42", "--env=develop", "--source") + if err != nil || !strings.HasSuffix(out, "\nSource:\n\n") { + t.Fatalf("%q %v", out, err) + } + out, err = runEdgeCommand(t, deps, "validate", "headers", "--app=42", "--env=develop", "--skip-build") + if err == nil || !strings.Contains(err.Error(), "1 worker(s) failed validation") || !strings.Contains(out, "is invalid: bad wasm") { + t.Fatalf("%s %v", out, err) + } +} + +func TestEdgeWorkersNonInteractiveIsNotConfirmation(t *testing.T) { + edgeCommandConfig(t) + dir := edgeTestProject(t) + for _, args := range [][]string{ + {"deploy", "headers", "--skip-build", "--non-interactive"}, + {"deploy", "headers", "--skip-build", "--skip-confirmation=false"}, + {"enable", "headers", "--skip-confirmation=false"}, + {"delete", "headers", "--force=false"}, + } { + api := &edgeCommandAPI{valid: true, workers: []edgeworkers.Worker{{ID: 9, Name: "headers"}}} + deps := edgeWorkersDeps{Getwd: func() (string, error) { return dir, nil }, Service: edgeworkers.Service{API: api}, Confirm: func(*cobra.Command, string, bool) (bool, error) { return false, appctx.ErrNonInteractive }} + args = append(args, "--app=42", "--env=production") + _, err := runEdgeCommand(t, deps, args...) + if err == nil { + t.Fatalf("accepted %v", args) + } + for _, call := range api.calls { + if call != "list" && call != "validate" { + t.Fatalf("mutation without confirmation: %v", api.calls) + } + } + } +} diff --git a/cmd/vip-next/edge_workers_test.go b/cmd/vip-next/edge_workers_test.go new file mode 100644 index 000000000..cf7be2e41 --- /dev/null +++ b/cmd/vip-next/edge_workers_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "reflect" + "sort" + "strings" + "testing" + + "github.com/Automattic/vip/internal/envalias" + "github.com/spf13/cobra" +) + +func TestEdgeWorkersRootAndParsing(t *testing.T) { + root := newRootCmd(&rootContext{}) + parent, _, err := root.Find([]string{"edge-workers"}) + if err != nil || parent.Name() != "edge-workers" { + t.Fatalf("parent %v %v", parent, err) + } + names := []string{} + for _, c := range parent.Commands() { + names = append(names, c.Name()) + } + sort.Strings(names) + if !reflect.DeepEqual(names, []string{"build", "delete", "deploy", "disable", "enable", "get", "init", "list", "new", "validate"}) { + t.Fatalf("commands %v", names) + } + for _, tc := range []struct { + args []string + wantArgs []string + values map[string]string + }{ + {[]string{"@example-app.develop", "edge-workers", "deploy", "headers", "-s", "-p=./project"}, []string{"headers"}, map[string]string{"app": "example-app", "env": "develop", "path": "./project", "skip-build": "true"}}, + {[]string{"edge-workers", "new", "headers", "-p", "./project", "-l=equals:/x"}, []string{"headers"}, map[string]string{"path": "./project", "location": "equals:/x"}}, + {[]string{"edge-workers", "new", "--path", "--", "@literal"}, []string{"@literal"}, map[string]string{"path": "\x00"}}, + {[]string{"edge-workers", "init", "--type=assemblyscript"}, []string{}, map[string]string{"type": "assemblyscript"}}, + } { + rewritten, app, env, err := envalias.Rewrite(tc.args) + if err != nil { + t.Fatal(err) + } + root := newRootCmd(&rootContext{aliasApp: app, aliasEnv: env}) + leaf, _, err := root.Find(rewritten) + if err != nil { + t.Fatal(err) + } + called := false + leaf.RunE = func(c *cobra.Command, args []string) error { + called = true + if strings.Join(args, "|") != strings.Join(tc.wantArgs, "|") { + t.Fatalf("args %v", args) + } + for k, v := range tc.values { + if f := c.Flag(k); f == nil || f.Value.String() != v { + t.Fatalf("flag %s=%v want %q", k, f, v) + } + } + return nil + } + root.SetArgs(prepareArgs(root, rewritten)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("handler not reached") + } + } +} + +func TestEdgeWorkersHelpDoesNotExposeBareFlagMarker(t *testing.T) { + root := newRootCmd(&rootContext{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"edge-workers", "new", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "\x00") || strings.Contains(out.String(), `\x00`) { + t.Fatalf("internal marker exposed in help: %q", out.String()) + } +} + +func TestEdgeWorkersHelpUsesGoExecutable(t *testing.T) { + parent, _, _ := newRootCmd(&rootContext{}).Find([]string{"edge-workers"}) + for _, command := range parent.Commands() { + t.Run(command.Name(), func(t *testing.T) { + root := newRootCmd(&rootContext{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"edge-workers", command.Name(), "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + _, examples, found := strings.Cut(out.String(), "Examples:\n") + if !found { + t.Fatalf("missing examples: %s", out.String()) + } + examples, _, _ = strings.Cut(examples, "\n\n") + for _, example := range strings.Split(strings.TrimSpace(examples), "\n") { + if !strings.HasPrefix(strings.TrimSpace(example), "vip-next ") { + t.Errorf("example invokes another runtime: %q", example) + } + } + }) + } +} diff --git a/cmd/vip-next/flags_node_parity_test.go b/cmd/vip-next/flags_node_parity_test.go index 90753d0e9..05484f52c 100644 --- a/cmd/vip-next/flags_node_parity_test.go +++ b/cmd/vip-next/flags_node_parity_test.go @@ -40,8 +40,19 @@ var nodeShortFlags = map[string]map[string]string{ "vip-next defensive-mode disable": {"app": "a", "env": "e"}, "vip-next defensive-mode configure": {"app": "a", "env": "e"}, - "vip-next logout": {}, // src/bin/vip-logout.ts - "vip-next whoami": {}, // src/bin/vip-whoami.ts + "vip-next logout": {}, // src/bin/vip-logout.ts + "vip-next whoami": {}, // src/bin/vip-whoami.ts + "vip-next edge-workers": {}, + "vip-next edge-workers init": {"type": "t"}, + "vip-next edge-workers new": {"path": "p", "location": "l"}, + "vip-next edge-workers build": {"path": "p", "all": "a"}, + "vip-next edge-workers validate": {"app": "a", "env": "e", "path": "p", "skip-build": "s"}, + "vip-next edge-workers list": {"app": "a", "env": "e", "format": "f"}, + "vip-next edge-workers get": {"app": "a", "env": "e", "source": "s"}, + "vip-next edge-workers deploy": {"app": "a", "env": "e", "path": "p", "skip-build": "s"}, + "vip-next edge-workers enable": {"app": "a", "env": "e", "skip-confirmation": "s"}, + "vip-next edge-workers disable": {"app": "a", "env": "e"}, + "vip-next edge-workers delete": {"app": "a", "env": "e", "force": "f"}, // src/bin/vip-logs.js:241-257 — type, limit, follow, format (f taken). "vip-next logs": {"app": "a", "env": "e", "type": "t", "limit": "l", "follow": "f"}, diff --git a/cmd/vip-next/root.go b/cmd/vip-next/root.go index 0f4151cb8..7922d3274 100644 --- a/cmd/vip-next/root.go +++ b/cmd/vip-next/root.go @@ -61,6 +61,7 @@ func newRootCmd(rc *rootContext) *cobra.Command { root.AddCommand(commands.LogoutCmd()) root.AddCommand(commands.NewWhoamiCmd()) root.AddCommand(commands.NewDefensiveModeCmd()) + root.AddCommand(commands.NewEdgeWorkersCmd()) root.AddCommand(commands.LogsCmd()) root.AddCommand(commands.SlowlogsCmd()) diff --git a/docs/CUTOVER-BREAKING-CHANGES.md b/docs/CUTOVER-BREAKING-CHANGES.md index 20a588599..9c1402367 100644 --- a/docs/CUTOVER-BREAKING-CHANGES.md +++ b/docs/CUTOVER-BREAKING-CHANGES.md @@ -56,6 +56,7 @@ behavior forward, but each one can break an existing script, so each needs a cha | 1.25 | `import validate-sql` line count for a newline-terminated file | counts one phantom trailing line | reports the physical line count | scripts parsing `Finished processing N lines.` | | 1.26 | `db phpmyadmin --print` streams | progress tracker and warning go to stdout before the URL | stdout contains only the URL; warning/progress go to stderr | command substitution or parsers that previously received progress text with the URL | | 1.27 | Interactive login banner | legacy uncolored `VIP-CLI` ASCII art | six-line `VIP-CLI 5` ANSI Shadow artwork in the VIP warm-color gradient | snapshot tests or tools scraping the login prompt | +| 1.28 | Edge Workers help examples and init/new next steps | invoke `vip` | invoke `vip-next` | users copying Go guidance now stay in the Go runtime; shared project templates are unchanged | **Decided exception — do NOT keep:** `config software update` rejecting _deprecated_ versions. Node accepts them; deprecated versions are exactly what you reach for during an incident diff --git a/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 90d1c3d9e..d6be1c1a3 100644 --- a/docs/EDGE-WORKERS.md +++ b/docs/EDGE-WORKERS.md @@ -8,6 +8,11 @@ edge workers with VIP-CLI. Use Node.js 22.19.0 or newer, npm 8 or newer, an authenticated VIP-CLI session, and access to the target application and environment. Start in a non-production environment. +The Go CLI exposes the same commands under `vip-next edge-workers`. Local AssemblyScript +builds still require Node.js and the project's npm dependencies; the Go CLI invokes the +installed compiler directly. Use `--skip-build` only when the existing artifact has been +separately reviewed against the intended source. + The platform API creates every new worker with `active: false`; create does not accept an active input. The API also applies a database default of inactive as defense in depth. VIP-CLI relies on this enforced contract: deploy uploads a new worker first, confirms the returned inactive state, @@ -159,6 +164,9 @@ By default, deploy stores the worker's UTF-8 entry file alongside the WASM binar archive the full project or shared modules. `get` omits source by default; pass `--source` to make the additional on-demand source query and print the stored value. +Source output preserves newlines and tabs, converts CRLF line endings to LF, and escapes other +terminal control characters. This display formatting does not change the stored source. + `--skip-source` means: do not store source on create; preserve stored source on update. Without the flag, an update replaces the stored source with the current entry file, including an empty file. The plan's `source` column shows the selected behavior before mutation. diff --git a/go.mod b/go.mod index f10b4fa99..5619855ce 100644 --- a/go.mod +++ b/go.mod @@ -32,5 +32,5 @@ require ( github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.38.0 ) diff --git a/internal/edgeworkers/api.go b/internal/edgeworkers/api.go new file mode 100644 index 000000000..5ab6b46aa --- /dev/null +++ b/internal/edgeworkers/api.go @@ -0,0 +1,173 @@ +package edgeworkers + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/gql/edgeworkerinput" + "github.com/Khan/genqlient/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +type APIClient struct{ Client graphql.Client } + +// APIError retains the server's messages without gqlparser's source/path +// prefixes. The command prints the individual diagnostics like Node's error +// link, then adds its operation-specific context. Rechallenge and 401 handling +// still run in the configured transport before this conversion. +type APIError struct { + Messages []string + Cause error +} + +func (e *APIError) Error() string { return strings.Join(e.Messages, "\n") } +func (e *APIError) Unwrap() error { return e.Cause } +func apiError(err error) error { + var list gqlerror.List + if !errors.As(err, &list) { + return err + } + messages := make([]string, len(list)) + for i, item := range list { + messages[i] = item.Message + } + return &APIError{Messages: messages, Cause: err} +} + +var errInvalidRead = errors.New("EdgeWorkers query returned an invalid response.") + +func workerFromFields(f gql.EdgeWorkerFields) Worker { + w := Worker{ID: f.Id, Name: f.Name, Active: f.Active, Phases: phaseStrings(f.Phases), OnFailure: string(f.OnFailure), CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt} + if f.Location != nil { + w.Location = &Location{Operator: string(f.Location.Operator), Value: f.Location.Value} + } + return w +} + +func phaseStrings(phases []gql.EdgeWorkerPhase) []string { + out := make([]string, len(phases)) + for i, p := range phases { + out[i] = string(p) + } + return out +} + +func (a APIClient) List(ctx context.Context, appID, envID int64) ([]Worker, error) { + r, err := gql.EdgeWorkers(gql.WithAllowGQLErrors(ctx), a.Client, appID, envID) + if err != nil { + return nil, apiError(err) + } + if r == nil || r.App == nil || len(r.App.Environments) == 0 || r.App.Environments[0] == nil || r.App.Environments[0].EdgeWorkers == nil { + return nil, errInvalidRead + } + workers := make([]Worker, 0, len(r.App.Environments[0].EdgeWorkers)) + for _, w := range r.App.Environments[0].EdgeWorkers { + if w == nil { + return nil, errInvalidRead + } + workers = append(workers, workerFromFields(w.EdgeWorkerFields)) + } + return workers, nil +} + +func (a APIClient) Get(ctx context.Context, appID, envID int64, name string, source bool) (*Worker, error) { + ctx = gql.WithAllowGQLErrors(ctx) + if source { + r, err := gql.EdgeWorkerDetailWithSource(ctx, a.Client, appID, envID) + if err != nil { + return nil, apiError(err) + } + if r == nil || r.App == nil || len(r.App.Environments) == 0 || r.App.Environments[0] == nil || r.App.Environments[0].EdgeWorkers == nil { + return nil, errInvalidRead + } + for _, f := range r.App.Environments[0].EdgeWorkers { + if f == nil { + return nil, errInvalidRead + } + if f.Name == name { + w := workerFromFields(f.EdgeWorkerFields) + w.Source = f.Source + return &w, nil + } + } + return nil, nil + } + r, err := gql.EdgeWorkerDetail(ctx, a.Client, appID, envID) + if err != nil { + return nil, apiError(err) + } + if r == nil || r.App == nil || len(r.App.Environments) == 0 || r.App.Environments[0] == nil || r.App.Environments[0].EdgeWorkers == nil { + return nil, errInvalidRead + } + for _, f := range r.App.Environments[0].EdgeWorkers { + if f == nil { + return nil, errInvalidRead + } + if f.Name == name { + w := workerFromFields(f.EdgeWorkerFields) + return &w, nil + } + } + return nil, nil +} + +func missingResult(operation string) error { return fmt.Errorf("%s returned no result.", operation) } + +func (a APIClient) Create(ctx context.Context, envID int64, input WriteInput) (Worker, error) { + r, err := gql.CreateEdgeWorker(gql.WithAllowGQLErrors(ctx), a.Client, &edgeworkerinput.Create{EnvironmentID: envID, Fields: input}) + if err != nil { + return Worker{}, apiError(err) + } + if r == nil || r.CreateEdgeWorker == nil { + return Worker{}, missingResult("createEdgeWorker") + } + return workerFromFields(r.CreateEdgeWorker.EdgeWorkerFields), nil +} + +func (a APIClient) Update(ctx context.Context, envID, workerID int64, input WriteInput) (Worker, error) { + r, err := gql.UpdateEdgeWorker(gql.WithAllowGQLErrors(ctx), a.Client, &edgeworkerinput.Update{EnvironmentID: envID, EdgeWorkerID: workerID, Fields: input}) + if err != nil { + return Worker{}, apiError(err) + } + if r == nil || r.UpdateEdgeWorker == nil { + return Worker{}, missingResult("updateEdgeWorker") + } + return workerFromFields(r.UpdateEdgeWorker.EdgeWorkerFields), nil +} + +func (a APIClient) SetActive(ctx context.Context, envID, workerID int64, active bool) (Worker, error) { + r, err := gql.SetEdgeWorkerActive(gql.WithAllowGQLErrors(ctx), a.Client, &gql.SetEdgeWorkerActiveInput{EnvironmentId: envID, EdgeWorkerId: workerID, Active: active}) + if err != nil { + return Worker{}, apiError(err) + } + if r == nil || r.SetEdgeWorkerActive == nil { + return Worker{}, missingResult("setEdgeWorkerActive") + } + return workerFromFields(r.SetEdgeWorkerActive.EdgeWorkerFields), nil +} + +func (a APIClient) Delete(ctx context.Context, envID, workerID int64) error { + r, err := gql.DeleteEdgeWorker(gql.WithAllowGQLErrors(ctx), a.Client, &gql.DeleteEdgeWorkerInput{EnvironmentId: envID, EdgeWorkerId: workerID}) + if err != nil { + return apiError(err) + } + if r == nil || r.DeleteEdgeWorker == nil || !*r.DeleteEdgeWorker { + return errors.New("deleteEdgeWorker did not confirm deletion.") + } + return nil +} + +func (a APIClient) Validate(ctx context.Context, envID int64, binary string) (ValidationResult, error) { + r, err := gql.ValidateEdgeWorker(gql.WithAllowGQLErrors(ctx), a.Client, &gql.ValidateEdgeWorkerInput{EnvironmentId: envID, WasmBinary: binary}) + if err != nil { + return ValidationResult{}, apiError(err) + } + if r == nil || r.ValidateEdgeWorker == nil { + return ValidationResult{}, missingResult("validateEdgeWorker") + } + v := r.ValidateEdgeWorker + return ValidationResult{Valid: v.Valid, Phases: phaseStrings(v.Phases), Errors: v.Errors}, nil +} diff --git a/internal/edgeworkers/api_test.go b/internal/edgeworkers/api_test.go new file mode 100644 index 000000000..283def287 --- /dev/null +++ b/internal/edgeworkers/api_test.go @@ -0,0 +1,256 @@ +package edgeworkers + +import ( + "bytes" + "context" + json "encoding/json/v2" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/rechallenge" + "github.com/Khan/genqlient/graphql" +) + +type edgeMemoryKeychain struct{ value string } + +func (m *edgeMemoryKeychain) Set(_, _, value string) error { m.value = value; return nil } +func (m *edgeMemoryKeychain) Get(string, string) (string, error) { + if m.value == "" { + return "", keychain.ErrNotFound + } + return m.value, nil +} +func (m *edgeMemoryKeychain) Delete(string, string) error { m.value = ""; return nil } + +func TestAPICreateRechallengeReplaysOnceAndCachesByOperation(t *testing.T) { + for _, key := range []string{"VIP_PROXY", "vip_proxy", "SOCKS_PROXY", "socks_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", "VIP_USE_SYSTEM_PROXY"} { + t.Setenv(key, "") + } + var srv *httptest.Server + calls := 0 + sessionCalls := 0 + opened := 0 + var bodies [][]byte + var headers []string + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expires := time.Now().Add(time.Hour).Format(time.RFC3339) + switch r.URL.Path { + case "/sessions": + sessionCalls++ + fmt.Fprintf(w, `{"challengeId":"c1","status":"pending","verificationUrl":"%s/verify","pollIntervalSeconds":1,"expiresAt":"%s"}`, srv.URL, expires) + case "/sessions/c1": + fmt.Fprintf(w, `{"challengeId":"c1","status":"verified","expiresAt":"%s","pollIntervalSeconds":1,"provider":"passkeys"}`, expires) + case "/sessions/c1/exchange": + fmt.Fprintf(w, `{"elevatedToken":{"token":"fixture-elevated","expiresAt":"%s","purpose":"createEdgeWorker"}}`, expires) + case "/graphql": + calls++ + body, _ := io.ReadAll(r.Body) + bodies = append(bodies, body) + headers = append(headers, r.Header.Get("x-elevated-token")) + if calls == 1 { + fmt.Fprintf(w, `{"errors":[{"message":"elevation required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"%s/sessions","statusPathTemplate":"%s/sessions/{challengeId}","exchangePathTemplate":"%s/sessions/{challengeId}/exchange","elevatedHeaderName":"x-elevated-token"}}}]}`, srv.URL, srv.URL, srv.URL) + return + } + fmt.Fprint(w, `{"data":{"createEdgeWorker":`+workerJSON+`}}`) + default: + t.Errorf("unexpected path %s", r.URL.Path) + http.Error(w, "unexpected", 400) + } + })) + defer srv.Close() + cache := &rechallenge.TokenCache{Keychain: &keychain.Keychain{Backend: &edgeMemoryKeychain{}, Service: "test-only"}} + runner := &rechallenge.Runner{Client: &rechallenge.Client{APIHost: srv.URL, HTTP: srv.Client()}, TokenCache: cache, Stdout: io.Discard, OpenURL: func(string) { opened++ }, Sleep: func(context.Context, time.Duration) error { return nil }} + client := gql.HTTPClientWithMiddleware(srv.URL, "fixture-token", []gql.Middleware{gql.NewErrorMiddleware(gql.ErrorConfig{ExitOnError: true, Exit: func(int) { t.Error("global exit on handled error") }, Stderr: io.Discard}), gql.NewRechallengeMiddleware(gql.RechallengeConfig{TokenCache: cache, Runner: runner, Interactive: func() bool { return true }, Stderr: io.Discard}), gql.NewRetryMiddleware(gql.RetryConfig{MaxAttempts: 3, NoDelay: true})}) + api := APIClient{Client: graphql.NewClient(srv.URL+"/graphql", client)} + for i := 0; i < 2; i++ { + if _, err := api.Create(context.Background(), 7, WriteInput{Name: "headers", WASMBinary: "AGFzbQ=="}); err != nil { + t.Fatal(err) + } + } + if calls != 3 || sessionCalls != 1 || opened != 1 || !bytes.Equal(bodies[0], bodies[1]) || !reflect.DeepEqual(headers, []string{"", "fixture-elevated", "fixture-elevated"}) { + t.Fatalf("calls=%d sessions=%d opened=%d headers=%v", calls, sessionCalls, opened, headers) + } + if token, _ := cache.Get("updateEdgeWorker"); token != nil { + t.Fatal("elevated token leaked to another operation") + } +} + +const workerJSON = `{"id":9,"name":"headers","location":null,"phases":["client_response"],"onFailure":"continue","active":false,"createdAt":"2026-08-28T00:00:00.000Z","updatedAt":"2026-08-28T00:00:00.000Z","source":""}` + +type apiRequest struct { + Operation string `json:"operationName"` + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +func testAPI(t *testing.T, body string) (APIClient, *[]apiRequest) { + t.Helper() + requests := []apiRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req apiRequest + if err := json.UnmarshalRead(r.Body, &req); err != nil { + t.Error(err) + } + requests = append(requests, req) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + t.Cleanup(srv.Close) + return APIClient{Client: graphql.NewClient(srv.URL, srv.Client())}, &requests +} + +func TestAPIReadsPreserveFieldsAndLimitSource(t *testing.T) { + for _, mode := range []string{"list", "get", "source"} { + t.Run(mode, func(t *testing.T) { + api, requests := testAPI(t, `{"data":{"app":{"environments":[{"id":7,"edgeWorkers":[`+workerJSON+`]}]}}}`) + var worker Worker + if mode == "list" { + workers, err := api.List(context.Background(), 42, 7) + if err != nil { + t.Fatal(err) + } + worker = workers[0] + } else { + w, err := api.Get(context.Background(), 42, 7, "headers", mode == "source") + if err != nil || w == nil { + t.Fatalf("%v %v", w, err) + } + worker = *w + } + if worker.ID != 9 || worker.Active || worker.CreatedAt != "2026-08-28T00:00:00.000Z" || !reflect.DeepEqual(worker.Phases, []string{"client_response"}) { + t.Fatalf("worker: %#v", worker) + } + if mode == "source" && (worker.Source == nil || *worker.Source != "") { + t.Fatalf("empty source lost: %#v", worker) + } + if len(*requests) != 1 { + t.Fatalf("requests: %v", *requests) + } + req := (*requests)[0] + if !strings.Contains(req.Query, "environments(id: $envId)") || req.Variables["appId"] != float64(42) || req.Variables["envId"] != float64(7) { + t.Fatalf("request: %#v", req) + } + if strings.Contains(req.Query, "wasmBinary") || strings.Contains(req.Query, "source") != (mode == "source") { + t.Fatalf("excessive fields: %s", req.Query) + } + }) + } +} + +func TestAPIReadEnvelopes(t *testing.T) { + for _, body := range []string{`{"data":null}`, `{"data":{"app":null}}`, `{"data":{"app":{"environments":[]}}}`, `{"data":{"app":{"environments":[null]}}}`, `{"data":{"app":{"environments":[{"edgeWorkers":null}]}}}`, `{"data":{"app":{"environments":[{"edgeWorkers":{}}]}}}`, `not json`} { + for _, mode := range []string{"list", "get", "source"} { + api, _ := testAPI(t, body) + var err error + if mode == "list" { + _, err = api.List(context.Background(), 42, 7) + } else { + _, err = api.Get(context.Background(), 42, 7, "headers", mode == "source") + } + if err == nil { + t.Fatalf("%s accepted %s", mode, body) + } + } + } + api, _ := testAPI(t, `{"data":{"app":{"environments":[{"edgeWorkers":[]}]}}}`) + workers, err := api.List(context.Background(), 42, 7) + if err != nil || workers == nil || len(workers) != 0 { + t.Fatalf("empty list: %v %v", workers, err) + } + worker, err := api.Get(context.Background(), 42, 7, "missing", false) + if err != nil || worker != nil { + t.Fatalf("missing: %v %v", worker, err) + } +} + +func TestAPIMutationPayloads(t *testing.T) { + empty := "" + input := WriteInput{Name: "headers", WASMBinary: "AGFzbQ==", Source: &empty, Location: LocationValue{Present: true}} + for _, tc := range []struct { + field, operation string + call func(APIClient) error + want map[string]any + }{ + {"createEdgeWorker", "CreateEdgeWorker", func(a APIClient) error { _, err := a.Create(context.Background(), 7, input); return err }, map[string]any{"environmentId": float64(7), "name": "headers", "wasmBinary": "AGFzbQ==", "source": ""}}, + {"updateEdgeWorker", "UpdateEdgeWorker", func(a APIClient) error { _, err := a.Update(context.Background(), 7, 9, input); return err }, map[string]any{"environmentId": float64(7), "edgeWorkerId": float64(9), "name": "headers", "wasmBinary": "AGFzbQ==", "source": "", "location": nil}}, + {"setEdgeWorkerActive", "SetEdgeWorkerActive", func(a APIClient) error { _, err := a.SetActive(context.Background(), 7, 9, false); return err }, map[string]any{"environmentId": float64(7), "edgeWorkerId": float64(9), "active": false}}, + {"deleteEdgeWorker", "DeleteEdgeWorker", func(a APIClient) error { return a.Delete(context.Background(), 7, 9) }, map[string]any{"environmentId": float64(7), "edgeWorkerId": float64(9)}}, + {"validateEdgeWorker", "ValidateEdgeWorker", func(a APIClient) error { + result, err := a.Validate(context.Background(), 7, "AGFzbQ==") + if err == nil && (result.Valid || !reflect.DeepEqual(result.Errors, []string{"invalid"})) { + t.Errorf("result: %#v", result) + } + return err + }, map[string]any{"environmentId": float64(7), "wasmBinary": "AGFzbQ=="}}, + } { + t.Run(tc.operation, func(t *testing.T) { + result := workerJSON + if tc.field == "deleteEdgeWorker" { + result = "true" + } + if tc.field == "validateEdgeWorker" { + result = `{"valid":false,"phases":[],"errors":["invalid"]}` + } + api, requests := testAPI(t, `{"data":{"`+tc.field+`":`+result+`}}`) + if err := tc.call(api); err != nil { + t.Fatal(err) + } + if len(*requests) != 1 || (*requests)[0].Operation != tc.operation || !reflect.DeepEqual((*requests)[0].Variables["input"], tc.want) { + t.Fatalf("requests: %#v", *requests) + } + for _, bad := range []string{`{"data":null}`, `{"data":{"` + tc.field + `":null}}`, `{"errors":[{"message":"denied"}]}`} { + a, _ := testAPI(t, bad) + if err := tc.call(a); err == nil { + t.Errorf("accepted %s", bad) + } + } + }) + } + a, _ := testAPI(t, `{"data":{"deleteEdgeWorker":false}}`) + if err := a.Delete(context.Background(), 7, 9); err == nil || err.Error() != "deleteEdgeWorker did not confirm deletion." { + t.Fatalf("delete: %v", err) + } +} + +func TestAPIErrorMiddlewareAndNoMutationRetry(t *testing.T) { + // Exercise the real middleware without routing our local fixture through a user's proxy. + for _, key := range []string{"VIP_PROXY", "vip_proxy", "SOCKS_PROXY", "socks_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", "VIP_USE_SYSTEM_PROXY"} { + t.Setenv(key, "") + } + for _, status := range []int{200, 401, 503} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + calls, exits := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(status) + fmt.Fprint(w, `{"errors":[{"message":"denied"}]}`) + })) + defer srv.Close() + var stderr bytes.Buffer + client := gql.HTTPClientWithMiddleware(srv.URL, "test-token", []gql.Middleware{ + gql.NewErrorMiddleware(gql.ErrorConfig{Stderr: &stderr, Exit: func(int) { exits++ }, ExitOnError: true}), + gql.NewRetryMiddleware(gql.RetryConfig{MaxAttempts: 3, NoDelay: true}), + }) + api := APIClient{Client: graphql.NewClient(srv.URL+"/graphql", client)} + _, err := api.Create(context.Background(), 7, WriteInput{Name: "headers", WASMBinary: "AGFzbQ=="}) + if err == nil || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + wantExits := 0 + if status == 401 { + wantExits = 1 + } + if exits != wantExits || (status != 401 && stderr.Len() != 0) { + t.Fatalf("exits=%d stderr=%q", exits, stderr.String()) + } + }) + } +} diff --git a/internal/edgeworkers/build.go b/internal/edgeworkers/build.go new file mode 100644 index 000000000..7160b64be --- /dev/null +++ b/internal/edgeworkers/build.go @@ -0,0 +1,188 @@ +package edgeworkers + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/text/encoding/unicode" +) + +type Artifact struct { + Path, Base64 string + SizeBytes int64 +} +type CompilerRequest struct { + Binary string + Args []string + Dir string + Env []string +} +type CompilerResult struct { + Stdout, Stderr string + ExitCode int +} +type CompilerRunner func(context.Context, CompilerRequest) (CompilerResult, error) +type Compiler struct{ Run CompilerRunner } + +func RunCompiler(ctx context.Context, req CompilerRequest) (CompilerResult, error) { + // On Windows, execute asc's JS entry through Node rather than cmd.exe: paths + // and arguments remain literal even when they contain shell metacharacters. + binary, args := req.Binary, req.Args + if runtime.GOOS == "windows" { + binary = "node" + args = append([]string{filepath.Join(req.Dir, "node_modules", "assemblyscript", "bin", "asc.js")}, args...) + } + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir = req.Dir + cmd.Env = req.Env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + result := CompilerResult{Stdout: stdout.String(), Stderr: stderr.String()} + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + return result, err +} + +func encodeArtifact(file string) (Artifact, error) { + data, err := os.ReadFile(file) + if err != nil { + return Artifact{}, err + } + return Artifact{Path: file, Base64: base64.StdEncoding.EncodeToString(data), SizeBytes: int64(len(data))}, nil +} + +func (c Compiler) Build(ctx context.Context, projectDir string, w LocalWorker) (Artifact, error) { + if _, err := ReadProjectDescriptor(projectDir); err != nil { + return Artifact{}, err + } + binName := "asc" + if runtime.GOOS == "windows" { + binName = "asc.cmd" + } + asc := filepath.Join(projectDir, "node_modules", ".bin", binName) + if !pathExists(asc) { + return Artifact{}, fmt.Errorf("The AssemblyScript compiler was not found at \"%s\". Run `npm install` in \"%s\" first.", asc, projectDir) + } + candidate, err := ResolvePathWithin(w.Dir, w.Manifest.Entry, "Worker entry") + if err != nil { + return Artifact{}, err + } + if !pathExists(candidate) { + return Artifact{}, fmt.Errorf("Worker entry file not found: \"%s\".", candidate) + } + entry, err := ResolveExistingPathWithin(w.Dir, w.Manifest.Entry, "Worker entry") + if err != nil { + return Artifact{}, err + } + if err := ValidateWorkerName(w.Manifest.Name, "worker name"); err != nil { + return Artifact{}, err + } + out, err := ResolveOutputPathWithin(projectDir, filepath.Join(BuildDir, w.Manifest.Name+".wasm"), "Worker build artifact", "Worker build directory") + if err != nil { + return Artifact{}, err + } + modules := filepath.Join(projectDir, "node_modules") + args := []string{entry, "--runtime", "stub", "--path", modules, "--outFile", out, "--optimizeLevel", "3", "--shrinkLevel", "2"} + if pathExists(filepath.Join(modules, "json-as")) { + args = append(args, "--transform", "json-as/transform") + } + env := []string{} + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if key != "NODE_OPTIONS" { + env = append(env, item) + } + } + run := c.Run + if run == nil { + run = RunCompiler + } + result, err := run(ctx, CompilerRequest{Binary: asc, Args: args, Dir: projectDir, Env: env}) + if err != nil { + return Artifact{}, fmt.Errorf("Failed to run the AssemblyScript compiler: %s", err) + } + if result.ExitCode != 0 { + details := result.Stderr + if details == "" { + details = result.Stdout + } + details = strings.TrimSpace(details) + suffix := "." + if details != "" { + suffix = ":\n" + details + } + return Artifact{}, fmt.Errorf("Compilation failed for worker \"%s\"%s", w.Manifest.Name, suffix) + } + out, err = ResolveOutputPathWithin(projectDir, filepath.Join(BuildDir, w.Manifest.Name+".wasm"), "Worker build artifact", "Worker build directory") + if err != nil { + return Artifact{}, err + } + return encodeArtifact(out) +} + +func ReadPrebuilt(projectDir string, w LocalWorker) (Artifact, error) { + if err := ValidateWorkerName(w.Manifest.Name, "worker name"); err != nil { + return Artifact{}, err + } + root, err := ResolvePathWithin(projectDir, BuildDir, "Worker build directory") + if err != nil { + return Artifact{}, err + } + candidate := filepath.Join(root, w.Manifest.Name+".wasm") + if !pathExists(candidate) { + return Artifact{}, fmt.Errorf("No compiled artifact found for \"%s\" at \"%s\". Run `vip edge-workers build` first, or deploy without `--skip-build`.", w.Manifest.Name, candidate) + } + for _, item := range []struct{ path, label string }{{root, "Worker build directory"}, {candidate, "Worker build artifact"}} { + stat, err := os.Lstat(item.path) + if err != nil { + return Artifact{}, err + } + if stat.Mode()&os.ModeSymlink != 0 { + return Artifact{}, fmt.Errorf("%s must not be a symbolic link.", item.label) + } + } + root, err = ResolveExistingPathWithin(projectDir, BuildDir, "Worker build directory") + if err != nil { + return Artifact{}, err + } + file, err := ResolveExistingPathWithin(root, w.Manifest.Name+".wasm", "Worker build artifact") + if err != nil { + return Artifact{}, err + } + return encodeArtifact(file) +} + +func ReadWorkerSource(w LocalWorker) (string, error) { + candidate, err := ResolvePathWithin(w.Dir, w.Manifest.Entry, "Worker entry") + if err != nil { + return "", err + } + if !pathExists(candidate) { + return "", fmt.Errorf("Could not read worker source at \"%s\".", candidate) + } + entry, err := ResolveExistingPathWithin(w.Dir, w.Manifest.Entry, "Worker entry") + if err != nil { + return "", err + } + data, err := os.ReadFile(entry) + if err != nil { + return "", fmt.Errorf("Could not read worker source at \"%s\".", entry) + } + // Match Buffer.toString('utf8'): replace each malformed sequence, not + // an entire run of malformed bytes (bytes.ToValidUTF8 collapses runs). + decoded, err := unicode.UTF8.NewDecoder().Bytes(data) + return string(decoded), err +} diff --git a/internal/edgeworkers/build_test.go b/internal/edgeworkers/build_test.go new file mode 100644 index 000000000..ac697b6ce --- /dev/null +++ b/internal/edgeworkers/build_test.go @@ -0,0 +1,175 @@ +package edgeworkers + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestScaffoldDoesNotOverwriteOrInstall(t *testing.T) { + dir := filepath.Join(t.TempDir(), "edge workers") + if err := ScaffoldProject(dir, "assemblyscript"); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(dir, "package.json")) + if !strings.Contains(string(before), `"0.3.2"`) || !strings.Contains(string(before), `"0.27.0"`) { + t.Fatalf("dependencies: %s", before) + } + if err := ScaffoldProject(dir, "assemblyscript"); err == nil { + t.Fatal("overwrote project") + } + after, _ := os.ReadFile(filepath.Join(dir, "package.json")) + if string(before) != string(after) { + t.Fatal("changed existing files") + } + if _, err := os.Stat(filepath.Join(dir, "node_modules")); !os.IsNotExist(err) { + t.Fatalf("installed: %v", err) + } + loc := &Location{Operator: "starts_with", Value: "/api/"} + if err := ScaffoldWorker(dir, "headers", loc); err != nil { + t.Fatal(err) + } + w, err := FindWorker(dir, "headers") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(w.Manifest.Location, LocationValue{Present: true, Value: loc}) { + t.Fatalf("manifest: %#v", w.Manifest) + } + source, err := ReadWorkerSource(w) + if err != nil || !strings.Contains(source, "export { alloc, on_client_response }") { + t.Fatalf("source %q %v", source, err) + } + if err := ScaffoldWorker(dir, "headers", nil); err == nil { + t.Fatal("overwrote worker") + } + if err := ScaffoldWorker(dir, "../escape", nil); err == nil { + t.Fatal("accepted traversal") + } + if err := ScaffoldWorker(dir, "bad", &Location{Operator: "bad", Value: "/"}); err == nil { + t.Fatal("accepted location") + } + if pathExists(filepath.Join(dir, WorkersDir, "bad")) { + t.Fatal("partial scaffold") + } + target := filepath.Join(t.TempDir(), "linked") + if err := os.Symlink(t.TempDir(), target); err != nil { + t.Fatal(err) + } + if err := ScaffoldProject(target, "assemblyscript"); err == nil { + t.Fatal("followed symlink") + } +} + +func TestCompilerArgumentsAndArtifacts(t *testing.T) { + dir := filepath.Join(t.TempDir(), "project with spaces & punctuation") + if err := ScaffoldProject(dir, "assemblyscript"); err != nil { + t.Fatal(err) + } + if err := ScaffoldWorker(dir, "headers", nil); err != nil { + t.Fatal(err) + } + w, _ := FindWorker(dir, "headers") + if _, err := (Compiler{}).Build(context.Background(), dir, w); err == nil || !strings.Contains(err.Error(), "npm install") { + t.Fatalf("missing asc: %v", err) + } + writeTestFile(t, dir, "node_modules/.bin/asc", "") + writeTestFile(t, dir, "node_modules/json-as/package.json", "{}") + t.Setenv("NODE_OPTIONS", "--inspect") + t.Setenv("EDGE_BUILD_TEST", "retained") + var seen CompilerRequest + compiler := Compiler{Run: func(_ context.Context, req CompilerRequest) (CompilerResult, error) { + seen = req + return CompilerResult{}, os.WriteFile(req.Args[6], []byte{0, 97, 115, 109}, 0600) + }} + art, err := compiler.Build(context.Background(), dir, w) + if err != nil { + t.Fatal(err) + } + entry, _ := filepath.EvalSymlinks(filepath.Join(w.Dir, w.Manifest.Entry)) + want := []string{entry, "--runtime", "stub", "--path", filepath.Join(dir, "node_modules"), "--outFile", art.Path, "--optimizeLevel", "3", "--shrinkLevel", "2", "--transform", "json-as/transform"} + if !reflect.DeepEqual(seen.Args, want) || seen.Dir != dir || seen.Binary != filepath.Join(dir, "node_modules/.bin/asc") { + t.Fatalf("request: %#v", seen) + } + for _, s := range seen.Env { + if strings.HasPrefix(s, "NODE_OPTIONS=") { + t.Fatal("inherited NODE_OPTIONS") + } + } + if !strings.Contains(strings.Join(seen.Env, "\n"), "EDGE_BUILD_TEST=retained") { + t.Fatal("lost environment") + } + if art.Base64 != "AGFzbQ==" || art.SizeBytes != 4 { + t.Fatalf("artifact: %#v", art) + } + pre, err := ReadPrebuilt(dir, w) + if err != nil || pre != art { + t.Fatalf("prebuilt %#v %v", pre, err) + } + for _, tc := range []struct { + result CompilerResult + err error + want string + }{ + {CompilerResult{ExitCode: 1, Stdout: "out", Stderr: " err "}, nil, "Compilation failed for worker \"headers\":\nerr"}, + {CompilerResult{ExitCode: 1}, nil, "Compilation failed for worker \"headers\"."}, + {CompilerResult{}, errors.New("launch"), "Failed to run the AssemblyScript compiler: launch"}, + } { + compiler.Run = func(context.Context, CompilerRequest) (CompilerResult, error) { return tc.result, tc.err } + _, err := compiler.Build(context.Background(), dir, w) + if err == nil || err.Error() != tc.want { + t.Fatalf("error %v want %s", err, tc.want) + } + } +} + +func TestSourceAndPrebuiltSafety(t *testing.T) { + dir := t.TempDir() + w := LocalWorker{Dir: dir, Manifest: Manifest{Name: "test", Entry: "entry.ts"}} + if _, err := ReadPrebuilt(dir, w); err == nil { + t.Fatal("accepted missing artifact") + } + if _, err := ReadWorkerSource(w); err == nil { + t.Fatal("accepted missing source") + } + writeTestFile(t, dir, "entry.ts", "") + src, err := ReadWorkerSource(w) + if err != nil || src != "" { + t.Fatalf("empty source %q %v", src, err) + } + outside := writeTestFile(t, t.TempDir(), "outside.wasm", "sentinel") + if err := os.Mkdir(filepath.Join(dir, BuildDir), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, BuildDir, "test.wasm")); err != nil { + t.Fatal(err) + } + if _, err := ReadPrebuilt(dir, w); err == nil || !strings.Contains(err.Error(), "symbolic link") { + t.Fatalf("artifact escape %v", err) + } +} + +func TestSourceUTF8ReplacementMatchesNode(t *testing.T) { + dir := t.TempDir() + w := LocalWorker{Dir: dir, Manifest: Manifest{Entry: "source.ts"}} + for _, tc := range []struct { + data []byte + want string + }{ + {[]byte{0xff, 0xff}, "\ufffd\ufffd"}, + {[]byte{0xe1, 0x80}, "\ufffd"}, + {[]byte{0xe1, 0x80, 0xff}, "\ufffd\ufffd"}, + } { + if err := os.WriteFile(filepath.Join(dir, "source.ts"), tc.data, 0600); err != nil { + t.Fatal(err) + } + got, err := ReadWorkerSource(w) + if err != nil || got != tc.want { + t.Fatalf("source %x => %q want %q (%v)", tc.data, got, tc.want, err) + } + } +} diff --git a/internal/edgeworkers/confirmation.go b/internal/edgeworkers/confirmation.go new file mode 100644 index 000000000..def151057 --- /dev/null +++ b/internal/edgeworkers/confirmation.go @@ -0,0 +1,72 @@ +package edgeworkers + +import ( + "errors" + "fmt" + "strings" +) + +type ProductionConfirmation struct { + Action, AppName, EnvType string + WorkerNames []string + EnableAfterDeploy, SkipConfirmation, NonInteractive bool +} + +func ConfirmProduction(req ProductionConfirmation, confirm func(string) (bool, error)) error { + if req.EnvType != "production" || req.SkipConfirmation { + return nil + } + action := req.Action + if action == "deploy" && req.EnableAfterDeploy { + action = "deploy and enable" + } + if req.NonInteractive { + return fmt.Errorf("Refusing to %s edge workers in production without confirmation. Pass --skip-confirmation to proceed non-interactively.", action) + } + target := EscapeTerminalText(req.AppName) + "." + EscapeTerminalText(req.EnvType) + var message string + if req.Action == "enable" { + if len(req.WorkerNames) == 0 { + return errors.New("No worker selected.") + } + message = fmt.Sprintf("Enable edge worker \"%s\" on %s?", EscapeTerminalText(req.WorkerNames[0]), target) + } else { + verb, preposition, label := "Deploy", "to", "edge workers" + if req.EnableAfterDeploy { + verb = "Deploy and enable" + preposition = "on" + } + if len(req.WorkerNames) == 1 { + label = "edge worker" + } + message = fmt.Sprintf("%s %d %s (%s) %s %s?", verb, len(req.WorkerNames), label, escapedJoin(req.WorkerNames, ", ", ""), preposition, target) + } + return confirmOrCancel(message, confirm) +} +func confirmOrCancel(message string, confirm func(string) (bool, error)) error { + ok, err := confirm(message) + if err != nil { + return err + } + if !ok { + return errors.New("Command cancelled by user.") + } + return nil +} +func ConfirmDeletion(app, env, name string, force bool, confirm func(string) (bool, error)) error { + if force { + return nil + } + return confirmOrCancel(fmt.Sprintf("Permanently delete edge worker \"%s\" from %s.%s?", EscapeTerminalText(name), EscapeTerminalText(app), EscapeTerminalText(env)), confirm) +} +func escapedJoin(values []string, separator, fallback string) string { + parts := make([]string, len(values)) + for i, s := range values { + parts[i] = EscapeTerminalText(s) + } + joined := strings.Join(parts, separator) + if joined == "" { + return fallback + } + return joined +} diff --git a/internal/edgeworkers/deployment.go b/internal/edgeworkers/deployment.go new file mode 100644 index 000000000..5f678b49c --- /dev/null +++ b/internal/edgeworkers/deployment.go @@ -0,0 +1,156 @@ +package edgeworkers + +import ( + "context" + "errors" + "fmt" + "strings" +) + +type ArtifactBuilder interface { + Build(context.Context, string, LocalWorker) (Artifact, error) +} +type Service struct { + API API + Builder ArtifactBuilder +} +type PlanOptions struct { + AppID, EnvID int64 + ProjectDir string + Workers []LocalWorker + SkipBuild, SkipValidate, SkipSource, Enable bool +} +type PlanItem struct { + Action string + Worker LocalWorker + Existing *Worker + Artifact Artifact + Validation string + Phases []string + Input WriteInput + CurrentLocation, ProposedLocation *Location + SourceMode string + EnableAfterDeploy, IntendedActive bool +} +type ApplyError struct { + AppliedNames []string + FailedName string + UnappliedNames []string + Cause error + Stage string + UploadCompleted bool + ActiveAfterUpload *bool +} + +func (e *ApplyError) Error() string { + if e.Cause != nil { + return e.Cause.Error() + } + return "Deployment failed." +} +func (e *ApplyError) Unwrap() error { return e.Cause } + +func (s Service) PreparePlan(ctx context.Context, opts PlanOptions) ([]PlanItem, error) { + remote, err := s.API.List(ctx, opts.AppID, opts.EnvID) + if err != nil { + return nil, err + } + byName := map[string]*Worker{} + for i := range remote { + byName[remote[i].Name] = &remote[i] + } + items := make([]PlanItem, 0, len(opts.Workers)) + for _, w := range opts.Workers { + item := PlanItem{Action: "create", Worker: w, Existing: byName[w.Manifest.Name], Validation: "skipped", Phases: []string{}, SourceMode: "store", EnableAfterDeploy: opts.Enable, IntendedActive: opts.Enable} + if opts.SkipBuild { + item.Artifact, err = ReadPrebuilt(opts.ProjectDir, w) + } else if s.Builder != nil { + item.Artifact, err = s.Builder.Build(ctx, opts.ProjectDir, w) + } else { + return nil, errors.New("No worker compiler configured.") + } + if err != nil { + return nil, err + } + if !opts.SkipValidate { + result, e := s.API.Validate(ctx, opts.EnvID, item.Artifact.Base64) + if e != nil { + return nil, e + } + if !result.Valid { + details := strings.Join(result.Errors, "; ") + if details == "" { + details = "unknown error" + } + return nil, fmt.Errorf("worker \"%s\" failed validation: %s", w.Manifest.Name, details) + } + item.Validation = "passed" + item.Phases = result.Phases + } + item.Input = WriteInput{Name: w.Manifest.Name, WASMBinary: item.Artifact.Base64, OnFailure: w.Manifest.OnFailure, Location: w.Manifest.Location} + if !opts.SkipSource { + source, e := ReadWorkerSource(w) + if e != nil { + return nil, e + } + item.Input.Source = &source + } else { + item.SourceMode = "omit" + } + item.ProposedLocation = w.Manifest.Location.Value + if item.Existing != nil { + item.Action = "update" + item.CurrentLocation = item.Existing.Location + item.IntendedActive = opts.Enable || item.Existing.Active + if !w.Manifest.Location.Present { + item.ProposedLocation = item.CurrentLocation + } + if opts.SkipSource { + item.SourceMode = "preserve" + } + } else if item.Input.Location.Value == nil { + item.Input.Location = LocationValue{} + } + items = append(items, item) + } + return items, nil +} + +func newApplyError(items []PlanItem, index int, applied []string, stage string, uploaded bool, active *bool, cause error) *ApplyError { + e := &ApplyError{AppliedNames: append([]string{}, applied...), FailedName: items[index].Worker.Manifest.Name, UnappliedNames: []string{}, Stage: stage, UploadCompleted: uploaded, ActiveAfterUpload: active, Cause: cause} + for _, item := range items[index+1:] { + e.UnappliedNames = append(e.UnappliedNames, item.Worker.Manifest.Name) + } + return e +} +func (s Service) ApplyPlan(ctx context.Context, envID int64, items []PlanItem, onApplied func(PlanItem, Worker) error) error { + applied := []string{} + for i, item := range items { + var result Worker + var err error + if item.Action == "create" { + result, err = s.API.Create(ctx, envID, item.Input) + } else if item.Existing == nil { + err = fmt.Errorf("Update plan for \"%s\" has no existing worker.", item.Worker.Manifest.Name) + } else { + result, err = s.API.Update(ctx, envID, item.Existing.ID, item.Input) + } + if err != nil { + return newApplyError(items, i, applied, "upload", false, nil, err) + } + if item.EnableAfterDeploy && !result.Active { + active := result.Active + result, err = s.API.SetActive(ctx, envID, result.ID, true) + if err != nil { + return newApplyError(items, i, applied, "enable", true, &active, err) + } + } + applied = append(applied, item.Worker.Manifest.Name) + if onApplied != nil { + if err := onApplied(item, result); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/edgeworkers/deployment_test.go b/internal/edgeworkers/deployment_test.go new file mode 100644 index 000000000..abddbdfb3 --- /dev/null +++ b/internal/edgeworkers/deployment_test.go @@ -0,0 +1,165 @@ +package edgeworkers + +import ( + "context" + "errors" + "reflect" + "testing" +) + +type recordingAPI struct { + API + calls []string + remote []Worker + inputs []WriteInput + failName string + enableErr error + valid bool +} + +func (a *recordingAPI) List(context.Context, int64, int64) ([]Worker, error) { + a.calls = append(a.calls, "list") + return a.remote, nil +} +func (a *recordingAPI) Validate(_ context.Context, _ int64, binary string) (ValidationResult, error) { + a.calls = append(a.calls, "validate:"+binary) + return ValidationResult{Valid: a.valid, Phases: []string{"on_client_response"}}, nil +} +func (a *recordingAPI) Create(_ context.Context, _ int64, in WriteInput) (Worker, error) { + a.calls = append(a.calls, "create:"+in.Name) + a.inputs = append(a.inputs, in) + if in.Name == a.failName { + return Worker{}, errors.New("upload failed") + } + return Worker{ID: 9, Name: in.Name}, nil +} +func (a *recordingAPI) Update(_ context.Context, _ int64, id int64, in WriteInput) (Worker, error) { + a.calls = append(a.calls, "update:"+in.Name) + a.inputs = append(a.inputs, in) + for _, w := range a.remote { + if w.ID == id { + return w, nil + } + } + return Worker{ID: id, Name: in.Name}, nil +} +func (a *recordingAPI) SetActive(_ context.Context, _, _ int64, active bool) (Worker, error) { + a.calls = append(a.calls, "enable") + return Worker{ID: 9, Active: active}, a.enableErr +} + +type buildFunc func(context.Context, string, LocalWorker) (Artifact, error) + +func (f buildFunc) Build(c context.Context, p string, w LocalWorker) (Artifact, error) { + return f(c, p, w) +} + +func TestApplyStopsAfterEnableFailure(t *testing.T) { + api := &recordingAPI{enableErr: errors.New("timeout")} + s := Service{API: api} + items := []PlanItem{{Action: "create", Worker: LocalWorker{Manifest: Manifest{Name: "a"}}, Input: WriteInput{Name: "a"}, EnableAfterDeploy: true}, {Action: "create", Worker: LocalWorker{Manifest: Manifest{Name: "b"}}, Input: WriteInput{Name: "b"}}} + err := s.ApplyPlan(context.Background(), 7, items, nil) + var partial *ApplyError + if !errors.As(err, &partial) { + t.Fatalf("error %v", err) + } + if !reflect.DeepEqual(api.calls, []string{"create:a", "enable"}) { + t.Fatalf("calls %v", api.calls) + } + if partial.Stage != "enable" || !partial.UploadCompleted || partial.ActiveAfterUpload == nil || *partial.ActiveAfterUpload || !reflect.DeepEqual(partial.UnappliedNames, []string{"b"}) { + t.Fatalf("partial %#v", partial) + } + api = &recordingAPI{failName: "b"} + items = append(items, PlanItem{Worker: LocalWorker{Manifest: Manifest{Name: "c"}}}) + items[0].EnableAfterDeploy = false + s.API = api + err = s.ApplyPlan(context.Background(), 7, items, nil) + if !errors.As(err, &partial) || !reflect.DeepEqual(partial.AppliedNames, []string{"a"}) || !reflect.DeepEqual(partial.UnappliedNames, []string{"c"}) || partial.Stage != "upload" { + t.Fatalf("partial %#v %v", partial, err) + } +} + +func TestPrepareAllBeforeMutations(t *testing.T) { + for _, validationFailure := range []bool{false, true} { + t.Run(map[bool]string{true: "validation", false: "compile"}[validationFailure], func(t *testing.T) { + api := &recordingAPI{valid: !validationFailure} + count := 0 + s := Service{API: api, Builder: buildFunc(func(_ context.Context, _ string, w LocalWorker) (Artifact, error) { + count++ + if count == 2 && !validationFailure { + return Artifact{}, errors.New("compile failed") + } + return Artifact{Base64: w.Manifest.Name}, nil + })} + items, err := s.PreparePlan(context.Background(), PlanOptions{Workers: []LocalWorker{{Manifest: Manifest{Name: "a"}}, {Manifest: Manifest{Name: "b"}}}, SkipSource: true}) + if err == nil || items != nil { + t.Fatalf("plan %v error %v", items, err) + } + for _, call := range api.calls { + if call != "list" && call != "validate:a" { + t.Fatalf("unexpected persistent operation: %v", api.calls) + } + } + }) + } +} + +func TestPreparePresenceAndActiveState(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "source.ts", "") + current := &Location{Operator: "contains", Value: "/old"} + proposed := &Location{Operator: "equals", Value: "/new"} + for _, existing := range []bool{false, true} { + for _, active := range []bool{false, true} { + for _, enable := range []bool{false, true} { + for _, skipSource := range []bool{false, true} { + for _, location := range []LocationValue{{}, {Present: true}, {Present: true, Value: proposed}} { + api := &recordingAPI{valid: true} + if existing { + api.remote = []Worker{{ID: 4, Name: "a", Active: active, Location: current}} + } + s := Service{API: api, Builder: buildFunc(func(context.Context, string, LocalWorker) (Artifact, error) { + return Artifact{Base64: "binary", SizeBytes: 6}, nil + })} + items, err := s.PreparePlan(context.Background(), PlanOptions{Workers: []LocalWorker{{Dir: dir, Manifest: Manifest{Name: "a", Entry: "source.ts", Location: location}}}, Enable: enable, SkipSource: skipSource}) + if err != nil || len(items) != 1 { + t.Fatalf("plan %v %v", items, err) + } + item := items[0] + if item.IntendedActive != (enable || existing && active) || item.EnableAfterDeploy != enable || item.Validation != "passed" { + t.Fatalf("plan %#v", item) + } + wantLocation := location.Value + if existing && !location.Present { + wantLocation = current + } + if !reflect.DeepEqual(item.ProposedLocation, wantLocation) { + t.Fatalf("scope %#v", item) + } + if (item.Input.Source == nil) != skipSource || item.Input.Source != nil && *item.Input.Source != "" { + t.Fatalf("source %#v", item.Input) + } + mode := "store" + if skipSource { + mode = "omit" + if existing { + mode = "preserve" + } + } + if item.SourceMode != mode { + t.Fatalf("mode %s", item.SourceMode) + } + if err := s.ApplyPlan(context.Background(), 7, items, nil); err != nil { + t.Fatal(err) + } + wantEnable := enable && !(existing && active) + gotEnable := api.calls[len(api.calls)-1] == "enable" + if wantEnable != gotEnable { + t.Fatalf("enable calls %v", api.calls) + } + } + } + } + } + } +} diff --git a/internal/edgeworkers/output.go b/internal/edgeworkers/output.go new file mode 100644 index 000000000..e8b49273e --- /dev/null +++ b/internal/edgeworkers/output.go @@ -0,0 +1,131 @@ +package edgeworkers + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Automattic/vip/internal/output" +) + +func EscapeTerminalText(s string) string { + return escapeTerminalControls(s, false) +} + +// EscapeTerminalSource preserves source layout. CRLF becomes LF; standalone +// carriage returns remain escaped so source cannot overwrite terminal output. +func EscapeTerminalSource(s string) string { + return escapeTerminalControls(strings.ReplaceAll(s, "\r\n", "\n"), true) +} + +func escapeTerminalControls(s string, source bool) string { + var out strings.Builder + for _, r := range s { + if source && (r == '\n' || r == '\t') { + out.WriteRune(r) + } else if r <= 31 || r >= 127 && r <= 159 { + fmt.Fprintf(&out, `\u%04x`, r) + } else { + out.WriteRune(r) + } + } + return out.String() +} +func locationText(location *Location, escape func(string) string) string { + if location == nil { + return "all requests" + } + return escape(location.Operator) + ` "` + escape(location.Value) + `"` +} +func activeLabel(active bool) string { + if active { + return "active" + } + return "inactive" +} +func ListRows(workers []Worker, format string) output.OrderedRows { + escape := EscapeTerminalText + if format == "json" { + escape = func(s string) string { return s } + } + rows := make(output.OrderedRows, 0, len(workers)) + for _, w := range workers { + phases := make([]string, len(w.Phases)) + for i, p := range w.Phases { + phases[i] = escape(p) + } + active := "no" + if w.Active { + active = "yes" + } + rows = append(rows, output.OrderedRow{{Key: "id", Value: w.ID}, {Key: "name", Value: escape(w.Name)}, {Key: "active", Value: active}, {Key: "phases", Value: strings.Join(phases, ", ")}, {Key: "location", Value: locationText(w.Location, escape)}, {Key: "on_failure", Value: escape(w.OnFailure)}, {Key: "modified", Value: escape(w.UpdatedAt)}}) + } + return rows +} +func DetailText(w Worker, source bool) string { + active := "no" + if w.Active { + active = "yes" + } + text := output.KeyValue([]output.Tuple{{Key: "ID", Value: strconv.FormatInt(w.ID, 10)}, {Key: "Name", Value: EscapeTerminalText(w.Name)}, {Key: "Active", Value: active}, {Key: "Phases", Value: escapedJoin(w.Phases, ", ", "")}, {Key: "Location", Value: locationText(w.Location, EscapeTerminalText)}, {Key: "On failure", Value: EscapeTerminalText(w.OnFailure)}, {Key: "Created", Value: EscapeTerminalText(w.CreatedAt)}, {Key: "Modified", Value: EscapeTerminalText(w.UpdatedAt)}}) + if source { + value := "(no source stored)" + if w.Source != nil { + value = EscapeTerminalSource(*w.Source) + } + text += "\n\nSource:\n" + value + } + return text +} +func PlanRows(items []PlanItem) output.OrderedRows { + rows := make(output.OrderedRows, 0, len(items)) + for _, item := range items { + current := "new" + if item.Existing != nil { + current = activeLabel(item.Existing.Active) + } + rows = append(rows, output.OrderedRow{{Key: "worker", Value: EscapeTerminalText(item.Worker.Manifest.Name)}, {Key: "action", Value: item.Action}, {Key: "current_active", Value: current}, {Key: "final_active", Value: activeLabel(item.IntendedActive)}, {Key: "current_scope", Value: locationText(item.CurrentLocation, EscapeTerminalText)}, {Key: "proposed_scope", Value: locationText(item.ProposedLocation, EscapeTerminalText)}, {Key: "validation", Value: item.Validation}, {Key: "phases", Value: escapedJoin(item.Phases, ", ", "none")}, {Key: "bytes", Value: strconv.FormatInt(item.Artifact.SizeBytes, 10)}, {Key: "source", Value: item.SourceMode}}) + } + return rows +} +func AppliedMessage(item PlanItem, w Worker) string { + name := EscapeTerminalText(item.Worker.Manifest.Name) + result := "" + if item.Action == "create" { + result = fmt.Sprintf("created \"%s\"; inactive", name) + if w.Active { + result = fmt.Sprintf("created \"%s\" and enabled it", name) + } + } else if w.Active { + result = fmt.Sprintf("updated \"%s\" and enabled it", name) + if item.Existing != nil && item.Existing.Active { + result = fmt.Sprintf("updated \"%s\"; remains active", name) + } + } else { + result = fmt.Sprintf("updated \"%s\"; remains inactive", name) + } + return fmt.Sprintf("✓ %s (%d bytes, phases: %s)", result, item.Artifact.SizeBytes, escapedJoin(w.Phases, ", ", "none")) +} +func InactiveCreateGuidance(names []string) string { + quoted := make([]string, len(names)) + for i, name := range names { + quoted[i] = `"` + EscapeTerminalText(name) + `"` + } + joined := strings.Join(quoted, ", ") + if len(names) == 1 { + return fmt.Sprintf("Review created inactive edge worker %s, then run `vip edge-workers enable ` when ready.", joined) + } + return fmt.Sprintf("Review created inactive edge workers %s, then run `vip edge-workers enable ` for each one when ready.", joined) +} +func PartialFailureMessage(e *ApplyError) string { + cause := EscapeTerminalText(e.Error()) + name := EscapeTerminalText(e.FailedName) + if e.Stage == "enable" { + active := "unknown" + if e.ActiveAfterUpload != nil { + active = activeLabel(*e.ActiveAfterUpload) + } + return fmt.Sprintf("Deployment uploaded \"%s\" and its last confirmed state was %s, but the enable request failed. Final active state is unknown; verify with `vip edge-workers get %s` or `vip edge-workers list`. Completed: %s. Not attempted: %s. Cause: %s", name, active, name, escapedJoin(e.AppliedNames, ", ", "none"), escapedJoin(e.UnappliedNames, ", ", "none"), cause) + } + return fmt.Sprintf("Deployment stopped at \"%s\". Applied: %s. Not applied: %s. Cause: %s", name, escapedJoin(e.AppliedNames, ", ", "none"), escapedJoin(e.UnappliedNames, ", ", "none"), cause) +} diff --git a/internal/edgeworkers/output_test.go b/internal/edgeworkers/output_test.go new file mode 100644 index 000000000..3e8b68ebf --- /dev/null +++ b/internal/edgeworkers/output_test.go @@ -0,0 +1,98 @@ +package edgeworkers + +import ( + "bytes" + json "encoding/json/v2" + "errors" + "github.com/Automattic/vip/internal/output" + "strings" + "testing" +) + +func TestListJSONPreservesControlValues(t *testing.T) { + var buf bytes.Buffer + name := "headers\x1b\n\u009b" + if err := output.Render(&buf, output.Format("json"), ListRows([]Worker{{Name: name}}, "json")); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), `headers\u001b\n\u009b`) { + t.Fatalf("JSON bytes: %q", buf.String()) + } + var rows []map[string]any + if err := json.Unmarshal(buf.Bytes(), &rows); err != nil || rows[0]["name"] != name { + t.Fatalf("JSON value: %v %v", rows, err) + } +} + +func TestDetailSourcePreservesFormatting(t *testing.T) { + for _, tc := range []struct{ name, source, want string }{ + {"newlines and tabs", "// café\n\nexport function run(): void {\n\treturn;\n}\n", "// café\n\nexport function run(): void {\n\treturn;\n}\n"}, + {"Windows line endings", "export {};\r\n\t// comment\r\n", "export {};\n\t// comment\n"}, + {"terminal controls", "\x00\a\b\v\f\r\x1b[2J\x7f\u0085\u009b31m", `\u0000\u0007\u0008\u000b\u000c\u000d\u001b[2J\u007f\u0085\u009b31m`}, + {"literal escape sequences", `// literal \u000a and \t`, `// literal \u000a and \t`}, + } { + t.Run(tc.name, func(t *testing.T) { + text := DetailText(Worker{Name: "headers\n\tforged", Source: &tc.source}, true) + metadata, source, found := strings.Cut(text, "\n\nSource:\n") + if !found || source != tc.want { + t.Fatalf("source = %q, want %q", source, tc.want) + } + if !strings.Contains(metadata, `+ Name: headers\u000a\u0009forged`) { + t.Fatalf("metadata escaping changed: %q", metadata) + } + }) + } +} + +func TestOutputAndConfirmation(t *testing.T) { + if got := EscapeTerminalText("a\n\x1b\x7f\u009b"); got != `a\u000a\u001b\u007f\u009b` { + t.Fatalf("escaped %q", got) + } + for _, env := range []string{"develop", "production"} { + for _, skip := range []bool{false, true} { + for _, nonInteractive := range []bool{false, true} { + calls := 0 + req := ProductionConfirmation{Action: "deploy", AppName: "app\n", EnvType: env, WorkerNames: []string{"a", "b"}, EnableAfterDeploy: true, SkipConfirmation: skip, NonInteractive: nonInteractive} + err := ConfirmProduction(req, func(message string) (bool, error) { + calls++ + if message != `Deploy and enable 2 edge workers (a, b) on app\u000a.production?` { + t.Fatalf("prompt %s", message) + } + return true, nil + }) + needs := env == "production" && !skip + if needs && nonInteractive { + if err == nil || !strings.Contains(err.Error(), "--skip-confirmation") { + t.Fatalf("refusal %v", err) + } + } else if err != nil { + t.Fatal(err) + } + if calls != btoi(needs && !nonInteractive) { + t.Fatalf("prompts %d", calls) + } + } + } + } + err := ConfirmProduction(ProductionConfirmation{EnvType: "production", Action: "enable", WorkerNames: []string{"a"}}, func(string) (bool, error) { return false, nil }) + if err == nil || err.Error() != "Command cancelled by user." { + t.Fatalf("cancel %v", err) + } + if err := ConfirmDeletion("app", "develop", "a", false, func(string) (bool, error) { return false, nil }); err == nil { + t.Fatal("deletion not cancelled") + } + if err := ConfirmDeletion("app", "production", "a", true, func(string) (bool, error) { t.Fatal("prompted despite force"); return false, nil }); err != nil { + t.Fatal(err) + } + active := false + msg := PartialFailureMessage(&ApplyError{FailedName: "a", Stage: "enable", UploadCompleted: true, ActiveAfterUpload: &active, Cause: errors.New("bad\nerror"), UnappliedNames: []string{"b"}}) + if !strings.Contains(msg, "Final active state is unknown") || !strings.Contains(msg, `Cause: bad\u000aerror`) { + t.Fatal(msg) + } +} +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/edgeworkers/project.go b/internal/edgeworkers/project.go new file mode 100644 index 000000000..e0978815e --- /dev/null +++ b/internal/edgeworkers/project.go @@ -0,0 +1,264 @@ +package edgeworkers + +import ( + "encoding/json/jsontext" + json "encoding/json/v2" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +const ProjectFile = "edge-workers.json" +const ManifestFile = "worker.json" +const WorkersDir = "workers" +const BuildDir = "build" +const ConventionalDir = "edge-workers" + +type ProjectDescriptor struct { + Type string + SDK *string +} +type Manifest struct { + Name, Entry string + Location LocationValue + OnFailure *string +} +type LocalWorker struct { + Dir string + Manifest Manifest +} + +func pathExists(path string) bool { _, err := os.Stat(path); return err == nil } + +func ResolveProjectDir(cwd string, explicit *string) (string, error) { + cwd, err := filepath.Abs(cwd) + if err != nil { + return "", err + } + if explicit != nil { + if *explicit == "" { + return "", errors.New("The --path flag requires a path to the edge-workers project.") + } + target := *explicit + if !filepath.IsAbs(target) { + target = filepath.Join(cwd, target) + } + target = filepath.Clean(target) + if !pathExists(filepath.Join(target, ProjectFile)) { + return "", fmt.Errorf("No edge-workers project found at \"%s\" (missing %s).", target, ProjectFile) + } + return target, nil + } + for current := cwd; ; current = filepath.Dir(current) { + if pathExists(filepath.Join(current, ProjectFile)) { + return current, nil + } + if filepath.Dir(current) == current { + break + } + } + conventional := filepath.Join(cwd, ConventionalDir) + if pathExists(filepath.Join(conventional, ProjectFile)) { + return conventional, nil + } + return "", errors.New("No edge-workers project found here. Run `vip edge-workers init` to create one, run the command from inside a project, or pass `--path` to point at one.") +} + +func readProjectJSON(file, label string) (any, error) { + info, err := os.Lstat(file) + if err != nil { + return nil, fmt.Errorf("Could not read %s at \"%s\".", label, file) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("%s at \"%s\" must not be a symbolic link.", label, file) + } + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("Could not read %s at \"%s\".", label, file) + } + var value any + // JSON.parse accepts duplicate properties (last wins) and replaces invalid UTF-8. + err = json.Unmarshal(data, &value, jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true)) + if err != nil { + return nil, fmt.Errorf("%s at \"%s\" is not valid JSON.", strings.ToUpper(label[:1])+label[1:], file) + } + return value, nil +} + +func ReadProjectDescriptor(projectDir string) (ProjectDescriptor, error) { + file := filepath.Join(projectDir, ProjectFile) + value, err := readProjectJSON(file, "project descriptor") + if err != nil { + return ProjectDescriptor{}, err + } + m, ok := value.(map[string]any) + if !ok { + return ProjectDescriptor{}, fmt.Errorf("Project descriptor at \"%s\" has an invalid \"type\" field.", file) + } + if _, present := m["type"]; !present { + return ProjectDescriptor{}, fmt.Errorf("Project descriptor at \"%s\" is missing a \"type\" field.", file) + } + if m["type"] != "assemblyscript" { + return ProjectDescriptor{}, fmt.Errorf("Project descriptor at \"%s\" has an invalid \"type\" field.", file) + } + d := ProjectDescriptor{Type: "assemblyscript"} + if raw, present := m["sdk"]; present { + sdk, ok := raw.(string) + if !ok { + return d, fmt.Errorf("Project descriptor at \"%s\" has an invalid \"sdk\" field.", file) + } + d.SDK = &sdk + } + return d, nil +} + +func ReadManifest(workerDir string) (Manifest, error) { + file := filepath.Join(workerDir, ManifestFile) + value, err := readProjectJSON(file, "worker manifest") + if err != nil { + return Manifest{}, err + } + m, ok := value.(map[string]any) + if !ok { + return Manifest{}, fmt.Errorf("Worker manifest at \"%s\" must be an object.", file) + } + name, ok := m["name"].(string) + if !ok { + raw, present := m["name"] + return Manifest{}, fmt.Errorf("Invalid worker name \"%s\".", jsString(raw, present)) + } + if err := ValidateWorkerName(name, "worker name"); err != nil { + return Manifest{}, err + } + entry, ok := m["entry"].(string) + if !ok || entry == "" { + return Manifest{}, fmt.Errorf("Worker manifest at \"%s\" is missing an \"entry\" field.", file) + } + if _, err := ResolvePathWithin(workerDir, entry, "Worker entry"); err != nil { + return Manifest{}, err + } + manifest := Manifest{Name: name, Entry: entry} + if raw, present := m["on_failure"]; present { + policy, ok := raw.(string) + if !ok || (policy != "continue" && policy != "error") { + return Manifest{}, fmt.Errorf("Worker manifest at \"%s\" has an invalid \"on_failure\" field.", file) + } + manifest.OnFailure = &policy + } + if raw, present := m["location"]; present { + manifest.Location.Present = true + if raw != nil { + location, ok := raw.(map[string]any) + if !ok { + return Manifest{}, fmt.Errorf("Worker manifest at \"%s\" has an invalid location.", file) + } + op, ok := location["operator"].(string) + if !ok || !validOperator(op) { + return Manifest{}, fmt.Errorf("Worker manifest at \"%s\" has an invalid location operator.", file) + } + val, ok := location["value"].(string) + if !ok || val == "" || hasTerminalControls(val) { + return Manifest{}, fmt.Errorf("Worker manifest at \"%s\" has an invalid location value.", file) + } + manifest.Location.Value = &Location{Operator: op, Value: val} + } + } + return manifest, nil +} + +// jsString is only used to retain Node's validation diagnostics for non-string fields. +func jsString(value any, present bool) string { + if !present { + return "undefined" + } + if value == nil { + return "null" + } + switch v := value.(type) { + case string: + return v + case map[string]any: + return "[object Object]" + case []any: + parts := make([]string, len(v)) + for i, x := range v { + if x != nil { + parts[i] = jsString(x, true) + } + } + return strings.Join(parts, ",") + default: + return fmt.Sprint(value) + } +} + +func DiscoverWorkers(projectDir string) ([]LocalWorker, error) { + root := filepath.Join(projectDir, WorkersDir) + entries, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return []LocalWorker{}, nil + } + if err != nil { + return nil, err + } + workers := []LocalWorker{} + names := map[string]bool{} + lower := cases.Lower(language.AmericanEnglish) + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(root, e.Name()) + if !pathExists(filepath.Join(dir, ManifestFile)) { + continue + } + m, err := ReadManifest(dir) + if err != nil { + return nil, err + } + normalized := lower.String(m.Name) + if names[normalized] { + return nil, fmt.Errorf("Duplicate worker name \"%s\" found in this project.", m.Name) + } + names[normalized] = true + workers = append(workers, LocalWorker{Dir: dir, Manifest: m}) + } + order := collate.New(language.AmericanEnglish) + sort.SliceStable(workers, func(i, j int) bool { + return order.CompareString(workers[i].Manifest.Name, workers[j].Manifest.Name) < 0 + }) + return workers, nil +} + +func FindWorker(projectDir, name string) (LocalWorker, error) { + workers, err := DiscoverWorkers(projectDir) + if err != nil { + return LocalWorker{}, err + } + for _, w := range workers { + if w.Manifest.Name == name { + return w, nil + } + } + for _, w := range workers { + if filepath.Base(w.Dir) == name { + return w, nil + } + } + names := make([]string, len(workers)) + for i, w := range workers { + names[i] = w.Manifest.Name + } + available := strings.Join(names, ", ") + if available == "" { + available = "(none)" + } + return LocalWorker{}, fmt.Errorf("No worker named \"%s\" found in this project. Available workers: %s.", name, available) +} diff --git a/internal/edgeworkers/project_test.go b/internal/edgeworkers/project_test.go new file mode 100644 index 000000000..5309b2261 --- /dev/null +++ b/internal/edgeworkers/project_test.go @@ -0,0 +1,167 @@ +package edgeworkers + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func writeTestFile(t *testing.T, root, name, body string) string { + t.Helper() + file := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(body), 0644); err != nil { + t.Fatal(err) + } + return file +} + +func TestManifestPresenceAndValidation(t *testing.T) { + dir := t.TempDir() + for _, tc := range []struct { + body string + present bool + wantErr string + }{ + {`{"name":"headers","entry":"assembly/index.ts"}`, false, ""}, + {`{"name":"headers","entry":"assembly/index.ts","location":null}`, true, ""}, + {`{"name":"headers","entry":"assembly/index.ts","location":{"operator":"equals","value":"/api/"}}`, true, ""}, + {`{"name":"headers","entry":"../escape"}`, false, "must stay within"}, + {`{"name":"headers","entry":"/escape"}`, false, "relative path"}, + {`{"name":"headers","entry":"index.ts","location":{"operator":"equals","value":"\n"}}`, false, "invalid location value"}, + {`{"name":"headers","entry":"index.ts","on_failure":null}`, false, "invalid \"on_failure\""}, + {`{"name":"headers"}`, false, "missing an \"entry\""}, + {`{"entry":"index.ts"}`, false, `Invalid worker name "undefined"`}, + {`[]`, false, "must be an object"}, + {`null`, false, "must be an object"}, + {`{`, false, "not valid JSON"}, + } { + writeTestFile(t, dir, "worker.json", tc.body) + m, err := ReadManifest(dir) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("%s: %v", tc.body, err) + } + continue + } + if err != nil || m.Location.Present != tc.present { + t.Fatalf("%s: %#v %v", tc.body, m, err) + } + } +} + +func TestWorkerNamesAndLocationOptions(t *testing.T) { + for _, name := range []string{"", ".", "..", "a/b", "a\\b", "CON", "lpt1.txt", "tail.", "tail ", "a\n", strings.Repeat("a", 65), strings.Repeat("😀", 33)} { + if err := ValidateWorkerName(name, "worker name"); err == nil { + t.Errorf("accepted %q", name) + } + } + for _, name := range []string{"headers", "café", "console", "COM10", strings.Repeat("😀", 32)} { + if err := ValidateWorkerName(name, "worker name"); err != nil { + t.Error(err) + } + } + for _, raw := range []string{"", "equals:", "bad:/api", "equals:/api\n"} { + if _, err := ParseLocationOption(raw); err == nil { + t.Errorf("accepted location %q", raw) + } + } + for _, op := range []string{"contains", "equals", "starts_with", "ends_with"} { + got, err := ParseLocationOption(op + ":/a:b") + if err != nil || got.Operator != op || got.Value != "/a:b" { + t.Fatalf("location: %#v %v", got, err) + } + } +} + +func TestProjectDiscoveryAndResolution(t *testing.T) { + root := t.TempDir() + project := filepath.Join(root, "edge-workers") + writeTestFile(t, project, "edge-workers.json", `{"type":"assemblyscript","sdk":"sdk"}`) + for dir, name := range map[string]string{"z-folder": "zebra", "a-folder": "Alpha", "b-folder": "beta"} { + writeTestFile(t, project, "workers/"+dir+"/worker.json", `{"name":"`+name+`","entry":"index.ts"}`) + } + for _, cwd := range []string{root, project, filepath.Join(project, "workers/a-folder")} { + got, err := ResolveProjectDir(cwd, nil) + if err != nil || got != project { + t.Fatalf("resolve %s: %s %v", cwd, got, err) + } + } + workers, err := DiscoverWorkers(project) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, w := range workers { + names = append(names, w.Manifest.Name) + } + if !reflect.DeepEqual(names, []string{"Alpha", "beta", "zebra"}) { + t.Fatalf("order: %v", names) + } + for _, name := range []string{"Alpha", "a-folder"} { + w, err := FindWorker(project, name) + if err != nil || w.Manifest.Name != "Alpha" { + t.Fatalf("find: %#v %v", w, err) + } + } + writeTestFile(t, project, "workers/duplicate/worker.json", `{"name":"ALPHA","entry":"index.ts"}`) + if _, err := DiscoverWorkers(project); err == nil || !strings.Contains(err.Error(), "Duplicate worker name") { + t.Fatalf("duplicate: %v", err) + } + empty := "" + if _, err := ResolveProjectDir(root, &empty); err == nil { + t.Fatal("accepted empty path") + } + missing := filepath.Join(root, "missing") + if _, err := ResolveProjectDir(project, &missing); err == nil { + t.Fatal("ignored explicit missing project") + } +} + +func TestProjectDescriptorValidation(t *testing.T) { + root := t.TempDir() + for _, body := range []string{`{}`, `null`, `[]`, `{"type":null}`, `{"type":"rust"}`, `{"type":"assemblyscript","sdk":false}`} { + writeTestFile(t, root, "edge-workers.json", body) + if _, err := ReadProjectDescriptor(root); err == nil { + t.Fatalf("accepted %s", body) + } + } + writeTestFile(t, root, "edge-workers.json", `{"type":"assemblyscript","extra":true}`) + if descriptor, err := ReadProjectDescriptor(root); err != nil || descriptor.Type != "assemblyscript" { + t.Fatalf("%#v %v", descriptor, err) + } +} + +func TestProjectSymlinksAndContainment(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + writeTestFile(t, outside, "worker.json", `{"name":"external","entry":"index.ts"}`) + if err := os.Symlink(filepath.Join(outside, "worker.json"), filepath.Join(root, "worker.json")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := ReadManifest(root); err == nil || !strings.Contains(err.Error(), "symbolic link") { + t.Fatalf("manifest link: %v", err) + } + if err := os.Symlink(outside, filepath.Join(root, "build")); err != nil { + t.Fatal(err) + } + if _, err := ResolveOutputPathWithin(root, "build/worker.wasm", "artifact", "build dir"); err == nil { + t.Fatal("accepted symlink output parent") + } + if _, err := ResolveExistingPathWithin(root, "build/worker.json", "entry"); err == nil { + t.Fatal("accepted entry escape") + } + if _, err := os.Stat(filepath.Join(outside, "worker.wasm")); !os.IsNotExist(err) { + t.Fatal("wrote outside project") + } + if _, err := ResolvePathWithin(root, "../outside", "entry"); err == nil { + t.Fatal("accepted traversal") + } + got, err := ResolveOutputPathWithin(root, "safe/nested/file.wasm", "artifact", "build dir") + if err != nil || !strings.HasSuffix(got, "safe/nested/file.wasm") { + t.Fatalf("safe output: %s %v", got, err) + } +} diff --git a/internal/edgeworkers/scaffold.go b/internal/edgeworkers/scaffold.go new file mode 100644 index 000000000..3293ed9d2 --- /dev/null +++ b/internal/edgeworkers/scaffold.go @@ -0,0 +1,88 @@ +package edgeworkers + +import ( + "embed" + "encoding/json/jsontext" + json "encoding/json/v2" + "fmt" + "os" + "path/filepath" +) + +//go:embed templates/* +var templates embed.FS + +func writeScaffoldFile(file string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + return err + } + return os.WriteFile(file, data, 0644) +} + +func ScaffoldProject(dir, projectType string) error { + if projectType != "assemblyscript" { + return fmt.Errorf("Unknown edge worker type \"%s\". Supported types: assemblyscript.", projectType) + } + if stat, err := os.Lstat(dir); err == nil { + if !stat.IsDir() { + return fmt.Errorf("Cannot create an edge-workers project at \"%s\": target is not a directory.", dir) + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + if len(entries) > 0 { + return fmt.Errorf("Cannot create an edge-workers project at \"%s\": target is not empty.", dir) + } + } else if !os.IsNotExist(err) { + return err + } + for _, name := range []string{ProjectFile, "package.json", "tsconfig.json", "gitignore", "README.md"} { + data, err := templates.ReadFile("templates/" + name) + if err != nil { + return err + } + target := name + if name == "gitignore" { + target = ".gitignore" + } + if err := writeScaffoldFile(filepath.Join(dir, target), data); err != nil { + return err + } + } + return writeScaffoldFile(filepath.Join(dir, WorkersDir, ".gitkeep"), nil) +} + +func ScaffoldWorker(projectDir, name string, location *Location) error { + if err := ValidateWorkerName(name, "worker name"); err != nil { + return err + } + if location != nil { + if _, err := ParseLocationOption(location.Operator + ":" + location.Value); err != nil { + return err + } + } + dir := filepath.Join(projectDir, WorkersDir, name) + if _, err := os.Lstat(dir); err == nil { + return fmt.Errorf("A worker directory already exists at \"%s\".", dir) + } else if !os.IsNotExist(err) { + return err + } + manifest := struct { + Name string `json:"name"` + Entry string `json:"entry"` + Location *Location `json:"location,omitempty"` + }{name, "assembly/index.ts", location} + data, err := json.Marshal(manifest, jsontext.WithIndent("\t")) + if err != nil { + return err + } + if err := writeScaffoldFile(filepath.Join(dir, ManifestFile), append(data, '\n')); err != nil { + return err + } + source, err := templates.ReadFile("templates/worker.ts") + if err != nil { + return err + } + return writeScaffoldFile(filepath.Join(dir, manifest.Entry), source) +} diff --git a/internal/edgeworkers/templates/README.md b/internal/edgeworkers/templates/README.md new file mode 100644 index 000000000..d8f8b93ed --- /dev/null +++ b/internal/edgeworkers/templates/README.md @@ -0,0 +1,25 @@ +# Edge workers + +AssemblyScript edge workers for your VIP environment. Each worker lives in its +own folder under `workers/` and is compiled to a `.wasm` binary that +runs at the edge. + +## Getting started + +```sh +npm install # install the SDK + compiler +vip edge-workers new my-worker # scaffold a new worker +# edit workers/my-worker/assembly/index.ts +vip @my-site.develop edge-workers deploy my-worker +``` + +Commit the generated `package-lock.json` after `npm install` so installs use the +reviewed dependency tree in local development and automation. + +Shared AssemblyScript modules go in `lib/` and can be imported from any worker. + +## Parsing JSON + +To work with JSON in a worker, install [json-as](https://www.npmjs.com/package/json-as) +(`npm install --save-dev json-as@^1.3.4`); the build enables its compiler +transform automatically when the package is present. diff --git a/internal/edgeworkers/templates/edge-workers.json b/internal/edgeworkers/templates/edge-workers.json new file mode 100644 index 000000000..2496dff02 --- /dev/null +++ b/internal/edgeworkers/templates/edge-workers.json @@ -0,0 +1,4 @@ +{ + "type": "assemblyscript", + "sdk": "@automattic/vip-edge-workers-sdk@0.3.2" +} diff --git a/internal/edgeworkers/templates/gitignore b/internal/edgeworkers/templates/gitignore new file mode 100644 index 000000000..b38db2f29 --- /dev/null +++ b/internal/edgeworkers/templates/gitignore @@ -0,0 +1,2 @@ +node_modules/ +build/ diff --git a/internal/edgeworkers/templates/package.json b/internal/edgeworkers/templates/package.json new file mode 100644 index 000000000..7fb11fc1c --- /dev/null +++ b/internal/edgeworkers/templates/package.json @@ -0,0 +1,16 @@ +{ + "name": "edge-workers", + "version": "0.0.0", + "private": true, + "description": "VIP edge workers", + "type": "module", + "scripts": { + "build": "vip edge-workers build --all" + }, + "dependencies": { + "@automattic/vip-edge-workers-sdk": "0.3.2" + }, + "devDependencies": { + "assemblyscript": "0.27.0" + } +} diff --git a/internal/edgeworkers/templates/tsconfig.json b/internal/edgeworkers/templates/tsconfig.json new file mode 100644 index 000000000..053ef6e04 --- /dev/null +++ b/internal/edgeworkers/templates/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": [ + "./**/*.ts" + ] +} diff --git a/internal/edgeworkers/templates/worker.ts b/internal/edgeworkers/templates/worker.ts new file mode 100644 index 000000000..77a81afbe --- /dev/null +++ b/internal/edgeworkers/templates/worker.ts @@ -0,0 +1,19 @@ +import { Response, onClientResponse } from '@automattic/vip-edge-workers-sdk'; + +export { alloc, on_client_response } from '@automattic/vip-edge-workers-sdk/assembly/index'; + +// Client response: runs before the response reaches the client. +onClientResponse( ( response: Response ): void => {} ); + +// Other available phases are intentionally inactive. To activate one, add its +// SDK type and hook to the import above, its host entrypoint to the export above, +// and its handler below. Do not export a phase without implementing its hook. +// +// Client request: Request, onClientRequest, on_client_request +// onClientRequest( ( request: Request ): void => {} ); +// +// Origin request: Request, onOriginRequest, on_origin_request +// onOriginRequest( ( request: Request ): void => {} ); +// +// Origin response: Response, onOriginResponse, on_origin_response +// onOriginResponse( ( response: Response ): void => {} ); diff --git a/internal/edgeworkers/types.go b/internal/edgeworkers/types.go new file mode 100644 index 000000000..01efa73a7 --- /dev/null +++ b/internal/edgeworkers/types.go @@ -0,0 +1,37 @@ +// Package edgeworkers implements local projects and the Edge Workers lifecycle. +package edgeworkers + +import ( + "context" + "github.com/Automattic/vip/internal/gql/edgeworkerinput" +) + +type Location = edgeworkerinput.Location +type LocationValue = edgeworkerinput.LocationValue +type WriteInput = edgeworkerinput.Fields + +type Worker struct { + ID int64 + Name string + Location *Location + Phases []string + OnFailure string + Active bool + CreatedAt, UpdatedAt string + Source *string +} + +type ValidationResult struct { + Valid bool + Phases, Errors []string +} + +type API interface { + List(context.Context, int64, int64) ([]Worker, error) + Get(context.Context, int64, int64, string, bool) (*Worker, error) + Create(context.Context, int64, WriteInput) (Worker, error) + Update(context.Context, int64, int64, WriteInput) (Worker, error) + SetActive(context.Context, int64, int64, bool) (Worker, error) + Delete(context.Context, int64, int64) error + Validate(context.Context, int64, string) (ValidationResult, error) +} diff --git a/internal/edgeworkers/validation.go b/internal/edgeworkers/validation.go new file mode 100644 index 000000000..3a66bdd5e --- /dev/null +++ b/internal/edgeworkers/validation.go @@ -0,0 +1,177 @@ +package edgeworkers + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "unicode/utf16" +) + +const LocationOperators = "contains, equals, starts_with, ends_with" + +var reservedName = regexp.MustCompile(`(?i)^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$`) + +func ValidateWorkerName(name, label string) error { + invalid := name == "" || name == "." || name == ".." || len(utf16.Encode([]rune(name))) > 64 || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") || reservedName.MatchString(name) + for _, r := range name { + if r <= 31 || strings.ContainsRune(`<>:"/\|?*`, r) { + invalid = true + } + } + if invalid { + return fmt.Errorf("Invalid %s \"%s\".", label, name) + } + return nil +} + +func hasTerminalControls(s string) bool { + for _, r := range s { + if r <= 31 || (r >= 127 && r <= 159) { + return true + } + } + return false +} + +func validOperator(s string) bool { + switch s { + case "contains", "equals", "starts_with", "ends_with": + return true + } + return false +} + +func ParseLocationOption(raw string) (Location, error) { + op, value, found := strings.Cut(raw, ":") + if !found || !validOperator(op) || value == "" || hasTerminalControls(value) { + return Location{}, fmt.Errorf("Invalid location \"%s\". Use \":\", where is one of: %s (e.g. \"starts_with:/api/\").", raw, LocationOperators) + } + return Location{Operator: op, Value: value}, nil +} + +func isWithin(root, candidate string) bool { + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func ResolvePathWithin(root, relative, label string) (string, error) { + if relative == "" || filepath.IsAbs(relative) { + return "", fmt.Errorf("%s must be a non-empty relative path.", label) + } + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + candidate := filepath.Join(abs, relative) + if !isWithin(abs, candidate) { + return "", fmt.Errorf("%s must stay within \"%s\".", label, abs) + } + return candidate, nil +} + +func canonicalPath(target, label string) (string, error) { + real, err := filepath.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("%s could not be resolved at \"%s\".", label, target) + } + return filepath.Abs(real) +} + +func ResolveExistingPathWithin(root, relative, label string) (string, error) { + candidate, err := ResolvePathWithin(root, relative, label) + if err != nil { + return "", err + } + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + realRoot, err := canonicalPath(abs, label+" root") + if err != nil { + return "", err + } + real, err := canonicalPath(candidate, label) + if err != nil { + return "", err + } + if !isWithin(realRoot, real) { + return "", fmt.Errorf("%s must stay within \"%s\".", label, realRoot) + } + return real, nil +} + +func ResolveOutputPathWithin(root, relative, fileLabel, directoryLabel string) (string, error) { + candidate, err := ResolvePathWithin(root, relative, fileLabel) + if err != nil { + return "", err + } + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + realRoot, err := canonicalPath(abs, fileLabel+" root") + if err != nil { + return "", err + } + parentRel, err := filepath.Rel(abs, filepath.Dir(candidate)) + if err != nil { + return "", err + } + current := abs + if parentRel != "." { + for _, component := range strings.Split(parentRel, string(filepath.Separator)) { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(current, 0755); err != nil { + return "", err + } + } else if err != nil { + return "", err + } else { + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s must not be a symbolic link.", directoryLabel) + } + if !info.IsDir() { + return "", fmt.Errorf("%s must be a directory.", directoryLabel) + } + } + real, err := canonicalPath(current, directoryLabel) + if err != nil { + return "", err + } + if !isWithin(realRoot, real) { + return "", fmt.Errorf("%s must stay within \"%s\".", directoryLabel, realRoot) + } + } + } + parent, err := canonicalPath(filepath.Dir(candidate), directoryLabel) + if err != nil { + return "", err + } + out := filepath.Join(parent, filepath.Base(candidate)) + info, err := os.Lstat(out) + if errors.Is(err, os.ErrNotExist) { + return out, nil + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("%s must not be a symbolic link.", fileLabel) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("%s must be a regular file.", fileLabel) + } + real, err := canonicalPath(out, fileLabel) + if err != nil { + return "", err + } + if !isWithin(realRoot, real) { + return "", fmt.Errorf("%s must stay within \"%s\".", fileLabel, realRoot) + } + return out, nil +} diff --git a/internal/gql/edgeworkerinput/input.go b/internal/gql/edgeworkerinput/input.go new file mode 100644 index 000000000..94218e196 --- /dev/null +++ b/internal/gql/edgeworkerinput/input.go @@ -0,0 +1,58 @@ +// Package edgeworkerinput preserves omitted, null, and empty write values. +// It is independent of the generated client, which binds its mutation inputs here. +package edgeworkerinput + +import json "encoding/json/v2" + +type Location struct { + Operator string `json:"operator"` + Value string `json:"value"` +} + +type LocationValue struct { + Present bool + Value *Location +} + +type Fields struct { + Name string + WASMBinary string + Location LocationValue + OnFailure *string + Source *string +} + +type Create struct { + EnvironmentID int64 + Fields +} + +type Update struct { + EnvironmentID int64 + EdgeWorkerID int64 + Fields +} + +func fieldsMap(envID int64, f Fields, update bool) map[string]any { + out := map[string]any{"environmentId": envID, "name": f.Name, "wasmBinary": f.WASMBinary} + if f.OnFailure != nil { + out["onFailure"] = *f.OnFailure + } + if f.Source != nil { + out["source"] = *f.Source + } + if f.Location.Present && (update || f.Location.Value != nil) { + out["location"] = f.Location.Value + } + return out +} + +func (in Create) MarshalJSON() ([]byte, error) { + return json.Marshal(fieldsMap(in.EnvironmentID, in.Fields, false)) +} + +func (in Update) MarshalJSON() ([]byte, error) { + out := fieldsMap(in.EnvironmentID, in.Fields, true) + out["edgeWorkerId"] = in.EdgeWorkerID + return json.Marshal(out) +} diff --git a/internal/gql/edgeworkerinput/input_test.go b/internal/gql/edgeworkerinput/input_test.go new file mode 100644 index 000000000..e5b4ee5c8 --- /dev/null +++ b/internal/gql/edgeworkerinput/input_test.go @@ -0,0 +1,52 @@ +package edgeworkerinput + +import ( + json "encoding/json/v2" + "reflect" + "testing" +) + +func TestWriteFieldPresence(t *testing.T) { + empty, failure := "", "error" + for _, tc := range []struct { + name string + location LocationValue + source *string + onFailure *string + want map[string]any + }{ + {"omitted", LocationValue{}, nil, nil, map[string]any{}}, + {"clear location", LocationValue{Present: true}, nil, nil, map[string]any{"location": nil}}, + {"replace location", LocationValue{Present: true, Value: &Location{"starts_with", "/api/"}}, nil, nil, map[string]any{"location": map[string]any{"operator": "starts_with", "value": "/api/"}}}, + {"empty source", LocationValue{}, &empty, nil, map[string]any{"source": ""}}, + {"failure policy", LocationValue{}, nil, &failure, map[string]any{"onFailure": "error"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fields := Fields{Name: "headers", WASMBinary: "AGFzbQ==", Location: tc.location, Source: tc.source, OnFailure: tc.onFailure} + for _, update := range []bool{false, true} { + var input any = Create{EnvironmentID: 7, Fields: fields} + want := map[string]any{"environmentId": float64(7), "name": "headers", "wasmBinary": "AGFzbQ=="} + for k, v := range tc.want { + if k != "location" || update || v != nil { + want[k] = v + } + } + if update { + input = Update{EnvironmentID: 7, EdgeWorkerID: 9, Fields: fields} + want["edgeWorkerId"] = float64(9) + } + data, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("update=%v: got %s, want %#v", update, data, want) + } + } + }) + } +} diff --git a/internal/gql/generated.go b/internal/gql/generated.go index e7ace8cd5..928d6b6b8 100644 --- a/internal/gql/generated.go +++ b/internal/gql/generated.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" + "github.com/Automattic/vip/internal/gql/edgeworkerinput" "github.com/Khan/genqlient/graphql" ) @@ -1966,6 +1967,143 @@ func (v *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload) Ge return v.Success } +// CreateEdgeWorkerCreateEdgeWorker includes the requested fields of the GraphQL type EdgeWorker. +// The GraphQL type's documentation follows. +// +// A WASM edge worker deployed to an environment. +type CreateEdgeWorkerCreateEdgeWorker struct { + EdgeWorkerFields `json:"-"` +} + +// GetId returns CreateEdgeWorkerCreateEdgeWorker.Id, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetId() int64 { return v.EdgeWorkerFields.Id } + +// GetName returns CreateEdgeWorkerCreateEdgeWorker.Name, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetName() string { return v.EdgeWorkerFields.Name } + +// GetLocation returns CreateEdgeWorkerCreateEdgeWorker.Location, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.EdgeWorkerFields.Location +} + +// GetPhases returns CreateEdgeWorkerCreateEdgeWorker.Phases, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetPhases() []EdgeWorkerPhase { + return v.EdgeWorkerFields.Phases +} + +// GetOnFailure returns CreateEdgeWorkerCreateEdgeWorker.OnFailure, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetOnFailure() EdgeWorkerOnFailure { + return v.EdgeWorkerFields.OnFailure +} + +// GetActive returns CreateEdgeWorkerCreateEdgeWorker.Active, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetActive() bool { return v.EdgeWorkerFields.Active } + +// GetCreatedAt returns CreateEdgeWorkerCreateEdgeWorker.CreatedAt, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetCreatedAt() string { return v.EdgeWorkerFields.CreatedAt } + +// GetUpdatedAt returns CreateEdgeWorkerCreateEdgeWorker.UpdatedAt, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerCreateEdgeWorker) GetUpdatedAt() string { return v.EdgeWorkerFields.UpdatedAt } + +func (v *CreateEdgeWorkerCreateEdgeWorker) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *CreateEdgeWorkerCreateEdgeWorker + graphql.NoUnmarshalJSON + } + firstPass.CreateEdgeWorkerCreateEdgeWorker = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.EdgeWorkerFields) + if err != nil { + return err + } + return nil +} + +type __premarshalCreateEdgeWorkerCreateEdgeWorker struct { + Id int64 `json:"id"` + + Name string `json:"name"` + + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + + Phases []EdgeWorkerPhase `json:"phases"` + + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + + Active bool `json:"active"` + + CreatedAt string `json:"createdAt"` + + UpdatedAt string `json:"updatedAt"` +} + +func (v *CreateEdgeWorkerCreateEdgeWorker) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *CreateEdgeWorkerCreateEdgeWorker) __premarshalJSON() (*__premarshalCreateEdgeWorkerCreateEdgeWorker, error) { + var retval __premarshalCreateEdgeWorkerCreateEdgeWorker + + retval.Id = v.EdgeWorkerFields.Id + retval.Name = v.EdgeWorkerFields.Name + retval.Location = v.EdgeWorkerFields.Location + retval.Phases = v.EdgeWorkerFields.Phases + retval.OnFailure = v.EdgeWorkerFields.OnFailure + retval.Active = v.EdgeWorkerFields.Active + retval.CreatedAt = v.EdgeWorkerFields.CreatedAt + retval.UpdatedAt = v.EdgeWorkerFields.UpdatedAt + return &retval, nil +} + +// CreateEdgeWorkerResponse is returned by CreateEdgeWorker on success. +type CreateEdgeWorkerResponse struct { + // Create and upload a new inactive WASM edge worker. Use setEdgeWorkerActive to enable it after review. + CreateEdgeWorker *CreateEdgeWorkerCreateEdgeWorker `json:"createEdgeWorker"` +} + +// GetCreateEdgeWorker returns CreateEdgeWorkerResponse.CreateEdgeWorker, and is useful for accessing the field via an interface. +func (v *CreateEdgeWorkerResponse) GetCreateEdgeWorker() *CreateEdgeWorkerCreateEdgeWorker { + return v.CreateEdgeWorker +} + +// Input for deleting an edge worker. +type DeleteEdgeWorkerInput struct { + // The identifier of the edge worker to delete. + EdgeWorkerId int64 `json:"edgeWorkerId"` + // The environment the worker belongs to. + EnvironmentId int64 `json:"environmentId"` +} + +// GetEdgeWorkerId returns DeleteEdgeWorkerInput.EdgeWorkerId, and is useful for accessing the field via an interface. +func (v *DeleteEdgeWorkerInput) GetEdgeWorkerId() int64 { return v.EdgeWorkerId } + +// GetEnvironmentId returns DeleteEdgeWorkerInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *DeleteEdgeWorkerInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// DeleteEdgeWorkerResponse is returned by DeleteEdgeWorker on success. +type DeleteEdgeWorkerResponse struct { + // Permanently delete a WASM edge worker. + DeleteEdgeWorker *bool `json:"deleteEdgeWorker"` +} + +// GetDeleteEdgeWorker returns DeleteEdgeWorkerResponse.DeleteEdgeWorker, and is useful for accessing the field via an interface. +func (v *DeleteEdgeWorkerResponse) GetDeleteEdgeWorker() *bool { return v.DeleteEdgeWorker } + // DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload includes the requested fields of the GraphQL type EnvironmentVariablesPayload. // The GraphQL type's documentation follows. // @@ -2235,92 +2373,686 @@ func (v *DevEnvAppInfoResponse) GetApp() *DevEnvAppInfoApp { return v.App } // An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. type DevEnvSyncSitesApp struct { // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. - Environments []*DevEnvSyncSitesAppEnvironmentsAppEnvironment `json:"environments"` + Environments []*DevEnvSyncSitesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns DevEnvSyncSitesApp.Environments, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesApp) GetEnvironments() []*DevEnvSyncSitesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type DevEnvSyncSitesAppEnvironmentsAppEnvironment struct { + // Get WordPress Site Details from SDS + WpSitesSDS *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList `json:"wpSitesSDS"` +} + +// GetWpSitesSDS returns DevEnvSyncSitesAppEnvironmentsAppEnvironment.WpSitesSDS, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironment) GetWpSitesSDS() *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList { + return v.WpSitesSDS +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList includes the requested fields of the GraphQL type WPSiteList. +// The GraphQL type's documentation follows. +// +// A paginated list of WordPress sites. +type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList struct { + // The total number of matching WordPress sites. + Total *int64 `json:"total"` + // The cursor for the next page of WordPress sites. + NextCursor *string `json:"nextCursor"` + // The WordPress sites returned in the current page. + Nodes []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite `json:"nodes"` +} + +// GetTotal returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Total, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetTotal() *int64 { + return v.Total +} + +// GetNextCursor returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.NextCursor, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNextCursor() *string { + return v.NextCursor +} + +// GetNodes returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Nodes, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNodes() []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite { + return v.Nodes +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite includes the requested fields of the GraphQL type WPSite. +// The GraphQL type's documentation follows. +// +// A WordPress site or subsite within an environment. +type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite struct { + // WordPress Site/Blog ID + BlogId *int64 `json:"blogId"` + // WordPress Home URL option + HomeUrl *string `json:"homeUrl"` + // WordPress Site URL option + SiteUrl *string `json:"siteUrl"` +} + +// GetBlogId returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.BlogId, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetBlogId() *int64 { + return v.BlogId +} + +// GetHomeUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.HomeUrl, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetHomeUrl() *string { + return v.HomeUrl +} + +// GetSiteUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.SiteUrl, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetSiteUrl() *string { + return v.SiteUrl +} + +// DevEnvSyncSitesResponse is returned by DevEnvSyncSites on success. +type DevEnvSyncSitesResponse struct { + // Retrieve a single application. + App *DevEnvSyncSitesApp `json:"app"` +} + +// GetApp returns DevEnvSyncSitesResponse.App, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesResponse) GetApp() *DevEnvSyncSitesApp { return v.App } + +// EdgeWorkerDetailApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type EdgeWorkerDetailApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*EdgeWorkerDetailAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns EdgeWorkerDetailApp.Environments, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailApp) GetEnvironments() []*EdgeWorkerDetailAppEnvironmentsAppEnvironment { + return v.Environments +} + +// EdgeWorkerDetailAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type EdgeWorkerDetailAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The WASM edge workers deployed to the environment. + EdgeWorkers []*EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker `json:"edgeWorkers"` +} + +// GetId returns EdgeWorkerDetailAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEdgeWorkers returns EdgeWorkerDetailAppEnvironmentsAppEnvironment.EdgeWorkers, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironment) GetEdgeWorkers() []*EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker { + return v.EdgeWorkers +} + +// EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker includes the requested fields of the GraphQL type EdgeWorker. +// The GraphQL type's documentation follows. +// +// A WASM edge worker deployed to an environment. +type EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker struct { + EdgeWorkerFields `json:"-"` +} + +// GetId returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetId() int64 { + return v.EdgeWorkerFields.Id +} + +// GetName returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Name, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetName() string { + return v.EdgeWorkerFields.Name +} + +// GetLocation returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Location, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.EdgeWorkerFields.Location +} + +// GetPhases returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Phases, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetPhases() []EdgeWorkerPhase { + return v.EdgeWorkerFields.Phases +} + +// GetOnFailure returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.OnFailure, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetOnFailure() EdgeWorkerOnFailure { + return v.EdgeWorkerFields.OnFailure +} + +// GetActive returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Active, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetActive() bool { + return v.EdgeWorkerFields.Active +} + +// GetCreatedAt returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.CreatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetCreatedAt() string { + return v.EdgeWorkerFields.CreatedAt +} + +// GetUpdatedAt returns EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.UpdatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetUpdatedAt() string { + return v.EdgeWorkerFields.UpdatedAt +} + +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker + graphql.NoUnmarshalJSON + } + firstPass.EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.EdgeWorkerFields) + if err != nil { + return err + } + return nil +} + +type __premarshalEdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker struct { + Id int64 `json:"id"` + + Name string `json:"name"` + + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + + Phases []EdgeWorkerPhase `json:"phases"` + + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + + Active bool `json:"active"` + + CreatedAt string `json:"createdAt"` + + UpdatedAt string `json:"updatedAt"` +} + +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *EdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) __premarshalJSON() (*__premarshalEdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker, error) { + var retval __premarshalEdgeWorkerDetailAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker + + retval.Id = v.EdgeWorkerFields.Id + retval.Name = v.EdgeWorkerFields.Name + retval.Location = v.EdgeWorkerFields.Location + retval.Phases = v.EdgeWorkerFields.Phases + retval.OnFailure = v.EdgeWorkerFields.OnFailure + retval.Active = v.EdgeWorkerFields.Active + retval.CreatedAt = v.EdgeWorkerFields.CreatedAt + retval.UpdatedAt = v.EdgeWorkerFields.UpdatedAt + return &retval, nil +} + +// EdgeWorkerDetailResponse is returned by EdgeWorkerDetail on success. +type EdgeWorkerDetailResponse struct { + // Retrieve a single application. + App *EdgeWorkerDetailApp `json:"app"` +} + +// GetApp returns EdgeWorkerDetailResponse.App, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailResponse) GetApp() *EdgeWorkerDetailApp { return v.App } + +// EdgeWorkerDetailWithSourceApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type EdgeWorkerDetailWithSourceApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns EdgeWorkerDetailWithSourceApp.Environments, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceApp) GetEnvironments() []*EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment { + return v.Environments +} + +// EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The WASM edge workers deployed to the environment. + EdgeWorkers []*EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker `json:"edgeWorkers"` +} + +// GetId returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEdgeWorkers returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment.EdgeWorkers, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironment) GetEdgeWorkers() []*EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker { + return v.EdgeWorkers +} + +// EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker includes the requested fields of the GraphQL type EdgeWorker. +// The GraphQL type's documentation follows. +// +// A WASM edge worker deployed to an environment. +type EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker struct { + EdgeWorkerFields `json:"-"` + // The original source code, if it was stored. Fetched on demand. + Source *string `json:"source"` +} + +// GetSource returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Source, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetSource() *string { + return v.Source +} + +// GetId returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetId() int64 { + return v.EdgeWorkerFields.Id +} + +// GetName returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Name, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetName() string { + return v.EdgeWorkerFields.Name +} + +// GetLocation returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Location, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.EdgeWorkerFields.Location +} + +// GetPhases returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Phases, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetPhases() []EdgeWorkerPhase { + return v.EdgeWorkerFields.Phases +} + +// GetOnFailure returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.OnFailure, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetOnFailure() EdgeWorkerOnFailure { + return v.EdgeWorkerFields.OnFailure +} + +// GetActive returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Active, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetActive() bool { + return v.EdgeWorkerFields.Active +} + +// GetCreatedAt returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.CreatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetCreatedAt() string { + return v.EdgeWorkerFields.CreatedAt +} + +// GetUpdatedAt returns EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.UpdatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetUpdatedAt() string { + return v.EdgeWorkerFields.UpdatedAt +} + +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker + graphql.NoUnmarshalJSON + } + firstPass.EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.EdgeWorkerFields) + if err != nil { + return err + } + return nil +} + +type __premarshalEdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker struct { + Source *string `json:"source"` + + Id int64 `json:"id"` + + Name string `json:"name"` + + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + + Phases []EdgeWorkerPhase `json:"phases"` + + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + + Active bool `json:"active"` + + CreatedAt string `json:"createdAt"` + + UpdatedAt string `json:"updatedAt"` +} + +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *EdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) __premarshalJSON() (*__premarshalEdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker, error) { + var retval __premarshalEdgeWorkerDetailWithSourceAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker + + retval.Source = v.Source + retval.Id = v.EdgeWorkerFields.Id + retval.Name = v.EdgeWorkerFields.Name + retval.Location = v.EdgeWorkerFields.Location + retval.Phases = v.EdgeWorkerFields.Phases + retval.OnFailure = v.EdgeWorkerFields.OnFailure + retval.Active = v.EdgeWorkerFields.Active + retval.CreatedAt = v.EdgeWorkerFields.CreatedAt + retval.UpdatedAt = v.EdgeWorkerFields.UpdatedAt + return &retval, nil +} + +// EdgeWorkerDetailWithSourceResponse is returned by EdgeWorkerDetailWithSource on success. +type EdgeWorkerDetailWithSourceResponse struct { + // Retrieve a single application. + App *EdgeWorkerDetailWithSourceApp `json:"app"` +} + +// GetApp returns EdgeWorkerDetailWithSourceResponse.App, and is useful for accessing the field via an interface. +func (v *EdgeWorkerDetailWithSourceResponse) GetApp() *EdgeWorkerDetailWithSourceApp { return v.App } + +// EdgeWorkerFields includes the GraphQL fields of EdgeWorker requested by the fragment EdgeWorkerFields. +// The GraphQL type's documentation follows. +// +// A WASM edge worker deployed to an environment. +type EdgeWorkerFields struct { + // The unique identifier for the edge worker. + Id int64 `json:"id"` + // The human-readable name of the edge worker. + Name string `json:"name"` + // An optional rule scoping which requests the worker runs on. Runs on all requests when null. + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + // The request lifecycle phases the worker runs in. + Phases []EdgeWorkerPhase `json:"phases"` + // The behavior to apply when the worker errors at runtime. + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + // Whether the worker is currently active. + Active bool `json:"active"` + // When the worker was created. + CreatedAt string `json:"createdAt"` + // When the worker was last modified. + UpdatedAt string `json:"updatedAt"` +} + +// GetId returns EdgeWorkerFields.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetId() int64 { return v.Id } + +// GetName returns EdgeWorkerFields.Name, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetName() string { return v.Name } + +// GetLocation returns EdgeWorkerFields.Location, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.Location +} + +// GetPhases returns EdgeWorkerFields.Phases, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetPhases() []EdgeWorkerPhase { return v.Phases } + +// GetOnFailure returns EdgeWorkerFields.OnFailure, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetOnFailure() EdgeWorkerOnFailure { return v.OnFailure } + +// GetActive returns EdgeWorkerFields.Active, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetActive() bool { return v.Active } + +// GetCreatedAt returns EdgeWorkerFields.CreatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetCreatedAt() string { return v.CreatedAt } + +// GetUpdatedAt returns EdgeWorkerFields.UpdatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFields) GetUpdatedAt() string { return v.UpdatedAt } + +// EdgeWorkerFieldsLocationEdgeWorkerLocation includes the requested fields of the GraphQL type EdgeWorkerLocation. +// The GraphQL type's documentation follows. +// +// A rule scoping which requests an edge worker runs on. +type EdgeWorkerFieldsLocationEdgeWorkerLocation struct { + // The operator used to match the request path. + Operator EdgeWorkerLocationOperator `json:"operator"` + // The value to compare the request path against. + Value string `json:"value"` +} + +// GetOperator returns EdgeWorkerFieldsLocationEdgeWorkerLocation.Operator, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFieldsLocationEdgeWorkerLocation) GetOperator() EdgeWorkerLocationOperator { + return v.Operator +} + +// GetValue returns EdgeWorkerFieldsLocationEdgeWorkerLocation.Value, and is useful for accessing the field via an interface. +func (v *EdgeWorkerFieldsLocationEdgeWorkerLocation) GetValue() string { return v.Value } + +// The operators available for matching an edge worker location. +type EdgeWorkerLocationOperator string + +const ( + // Match when the path contains the value. + EdgeWorkerLocationOperatorContains EdgeWorkerLocationOperator = "contains" + // Match when the path exactly equals the value. + EdgeWorkerLocationOperatorEquals EdgeWorkerLocationOperator = "equals" + // Match when the path starts with the value. + EdgeWorkerLocationOperatorStartsWith EdgeWorkerLocationOperator = "starts_with" + // Match when the path ends with the value. + EdgeWorkerLocationOperatorEndsWith EdgeWorkerLocationOperator = "ends_with" +) + +var AllEdgeWorkerLocationOperator = []EdgeWorkerLocationOperator{ + EdgeWorkerLocationOperatorContains, + EdgeWorkerLocationOperatorEquals, + EdgeWorkerLocationOperatorStartsWith, + EdgeWorkerLocationOperatorEndsWith, +} + +// The behavior to apply when an edge worker errors at runtime. +type EdgeWorkerOnFailure string + +const ( + // Continue serving the request as if the worker had not run. + EdgeWorkerOnFailureContinue EdgeWorkerOnFailure = "continue" + // Fail the request when the worker errors. + EdgeWorkerOnFailureError EdgeWorkerOnFailure = "error" +) + +var AllEdgeWorkerOnFailure = []EdgeWorkerOnFailure{ + EdgeWorkerOnFailureContinue, + EdgeWorkerOnFailureError, +} + +// The request lifecycle phases an edge worker can run in. +type EdgeWorkerPhase string + +const ( + // Run while the request is being processed. + EdgeWorkerPhaseRequest EdgeWorkerPhase = "request" + // Run while the response is being processed. + EdgeWorkerPhaseResponse EdgeWorkerPhase = "response" +) + +var AllEdgeWorkerPhase = []EdgeWorkerPhase{ + EdgeWorkerPhaseRequest, + EdgeWorkerPhaseResponse, +} + +// EdgeWorkersApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type EdgeWorkersApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*EdgeWorkersAppEnvironmentsAppEnvironment `json:"environments"` } -// GetEnvironments returns DevEnvSyncSitesApp.Environments, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesApp) GetEnvironments() []*DevEnvSyncSitesAppEnvironmentsAppEnvironment { +// GetEnvironments returns EdgeWorkersApp.Environments, and is useful for accessing the field via an interface. +func (v *EdgeWorkersApp) GetEnvironments() []*EdgeWorkersAppEnvironmentsAppEnvironment { return v.Environments } -// DevEnvSyncSitesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// EdgeWorkersAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. // The GraphQL type's documentation follows. // // An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. -type DevEnvSyncSitesAppEnvironmentsAppEnvironment struct { - // Get WordPress Site Details from SDS - WpSitesSDS *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList `json:"wpSitesSDS"` +type EdgeWorkersAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The WASM edge workers deployed to the environment. + EdgeWorkers []*EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker `json:"edgeWorkers"` } -// GetWpSitesSDS returns DevEnvSyncSitesAppEnvironmentsAppEnvironment.WpSitesSDS, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironment) GetWpSitesSDS() *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList { - return v.WpSitesSDS +// GetId returns EdgeWorkersAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEdgeWorkers returns EdgeWorkersAppEnvironmentsAppEnvironment.EdgeWorkers, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironment) GetEdgeWorkers() []*EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker { + return v.EdgeWorkers } -// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList includes the requested fields of the GraphQL type WPSiteList. +// EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker includes the requested fields of the GraphQL type EdgeWorker. // The GraphQL type's documentation follows. // -// A paginated list of WordPress sites. -type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList struct { - // The total number of matching WordPress sites. - Total *int64 `json:"total"` - // The cursor for the next page of WordPress sites. - NextCursor *string `json:"nextCursor"` - // The WordPress sites returned in the current page. - Nodes []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite `json:"nodes"` +// A WASM edge worker deployed to an environment. +type EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker struct { + EdgeWorkerFields `json:"-"` } -// GetTotal returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Total, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetTotal() *int64 { - return v.Total +// GetId returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Id, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetId() int64 { + return v.EdgeWorkerFields.Id } -// GetNextCursor returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.NextCursor, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNextCursor() *string { - return v.NextCursor +// GetName returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Name, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetName() string { + return v.EdgeWorkerFields.Name } -// GetNodes returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Nodes, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNodes() []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite { - return v.Nodes +// GetLocation returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Location, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.EdgeWorkerFields.Location } -// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite includes the requested fields of the GraphQL type WPSite. -// The GraphQL type's documentation follows. -// -// A WordPress site or subsite within an environment. -type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite struct { - // WordPress Site/Blog ID - BlogId *int64 `json:"blogId"` - // WordPress Home URL option - HomeUrl *string `json:"homeUrl"` - // WordPress Site URL option - SiteUrl *string `json:"siteUrl"` +// GetPhases returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Phases, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetPhases() []EdgeWorkerPhase { + return v.EdgeWorkerFields.Phases } -// GetBlogId returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.BlogId, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetBlogId() *int64 { - return v.BlogId +// GetOnFailure returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.OnFailure, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetOnFailure() EdgeWorkerOnFailure { + return v.EdgeWorkerFields.OnFailure } -// GetHomeUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.HomeUrl, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetHomeUrl() *string { - return v.HomeUrl +// GetActive returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.Active, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetActive() bool { + return v.EdgeWorkerFields.Active } -// GetSiteUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.SiteUrl, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetSiteUrl() *string { - return v.SiteUrl +// GetCreatedAt returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.CreatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetCreatedAt() string { + return v.EdgeWorkerFields.CreatedAt } -// DevEnvSyncSitesResponse is returned by DevEnvSyncSites on success. -type DevEnvSyncSitesResponse struct { +// GetUpdatedAt returns EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker.UpdatedAt, and is useful for accessing the field via an interface. +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) GetUpdatedAt() string { + return v.EdgeWorkerFields.UpdatedAt +} + +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker + graphql.NoUnmarshalJSON + } + firstPass.EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.EdgeWorkerFields) + if err != nil { + return err + } + return nil +} + +type __premarshalEdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker struct { + Id int64 `json:"id"` + + Name string `json:"name"` + + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + + Phases []EdgeWorkerPhase `json:"phases"` + + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + + Active bool `json:"active"` + + CreatedAt string `json:"createdAt"` + + UpdatedAt string `json:"updatedAt"` +} + +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *EdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker) __premarshalJSON() (*__premarshalEdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker, error) { + var retval __premarshalEdgeWorkersAppEnvironmentsAppEnvironmentEdgeWorkersEdgeWorker + + retval.Id = v.EdgeWorkerFields.Id + retval.Name = v.EdgeWorkerFields.Name + retval.Location = v.EdgeWorkerFields.Location + retval.Phases = v.EdgeWorkerFields.Phases + retval.OnFailure = v.EdgeWorkerFields.OnFailure + retval.Active = v.EdgeWorkerFields.Active + retval.CreatedAt = v.EdgeWorkerFields.CreatedAt + retval.UpdatedAt = v.EdgeWorkerFields.UpdatedAt + return &retval, nil +} + +// EdgeWorkersResponse is returned by EdgeWorkers on success. +type EdgeWorkersResponse struct { // Retrieve a single application. - App *DevEnvSyncSitesApp `json:"app"` + App *EdgeWorkersApp `json:"app"` } -// GetApp returns DevEnvSyncSitesResponse.App, and is useful for accessing the field via an interface. -func (v *DevEnvSyncSitesResponse) GetApp() *DevEnvSyncSitesApp { return v.App } +// GetApp returns EdgeWorkersResponse.App, and is useful for accessing the field via an interface. +func (v *EdgeWorkersResponse) GetApp() *EdgeWorkersApp { return v.App } // EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload includes the requested fields of the GraphQL type EnablePhpMyAdminPayload. // The GraphQL type's documentation follows. @@ -4140,6 +4872,149 @@ type ResolveAppByNameResponse struct { // GetApps returns ResolveAppByNameResponse.Apps, and is useful for accessing the field via an interface. func (v *ResolveAppByNameResponse) GetApps() *ResolveAppByNameAppsAppList { return v.Apps } +// Input for enabling or disabling an edge worker. +type SetEdgeWorkerActiveInput struct { + // Whether the worker should be active. + Active bool `json:"active"` + // The identifier of the edge worker to toggle. + EdgeWorkerId int64 `json:"edgeWorkerId"` + // The environment the worker belongs to. + EnvironmentId int64 `json:"environmentId"` +} + +// GetActive returns SetEdgeWorkerActiveInput.Active, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveInput) GetActive() bool { return v.Active } + +// GetEdgeWorkerId returns SetEdgeWorkerActiveInput.EdgeWorkerId, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveInput) GetEdgeWorkerId() int64 { return v.EdgeWorkerId } + +// GetEnvironmentId returns SetEdgeWorkerActiveInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// SetEdgeWorkerActiveResponse is returned by SetEdgeWorkerActive on success. +type SetEdgeWorkerActiveResponse struct { + // Enable or disable an existing WASM edge worker. + SetEdgeWorkerActive *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker `json:"setEdgeWorkerActive"` +} + +// GetSetEdgeWorkerActive returns SetEdgeWorkerActiveResponse.SetEdgeWorkerActive, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveResponse) GetSetEdgeWorkerActive() *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker { + return v.SetEdgeWorkerActive +} + +// SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker includes the requested fields of the GraphQL type EdgeWorker. +// The GraphQL type's documentation follows. +// +// A WASM edge worker deployed to an environment. +type SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker struct { + EdgeWorkerFields `json:"-"` +} + +// GetId returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.Id, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetId() int64 { + return v.EdgeWorkerFields.Id +} + +// GetName returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.Name, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetName() string { + return v.EdgeWorkerFields.Name +} + +// GetLocation returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.Location, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.EdgeWorkerFields.Location +} + +// GetPhases returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.Phases, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetPhases() []EdgeWorkerPhase { + return v.EdgeWorkerFields.Phases +} + +// GetOnFailure returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.OnFailure, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetOnFailure() EdgeWorkerOnFailure { + return v.EdgeWorkerFields.OnFailure +} + +// GetActive returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.Active, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetActive() bool { + return v.EdgeWorkerFields.Active +} + +// GetCreatedAt returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.CreatedAt, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetCreatedAt() string { + return v.EdgeWorkerFields.CreatedAt +} + +// GetUpdatedAt returns SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker.UpdatedAt, and is useful for accessing the field via an interface. +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) GetUpdatedAt() string { + return v.EdgeWorkerFields.UpdatedAt +} + +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker + graphql.NoUnmarshalJSON + } + firstPass.SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.EdgeWorkerFields) + if err != nil { + return err + } + return nil +} + +type __premarshalSetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker struct { + Id int64 `json:"id"` + + Name string `json:"name"` + + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + + Phases []EdgeWorkerPhase `json:"phases"` + + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + + Active bool `json:"active"` + + CreatedAt string `json:"createdAt"` + + UpdatedAt string `json:"updatedAt"` +} + +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker) __premarshalJSON() (*__premarshalSetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker, error) { + var retval __premarshalSetEdgeWorkerActiveSetEdgeWorkerActiveEdgeWorker + + retval.Id = v.EdgeWorkerFields.Id + retval.Name = v.EdgeWorkerFields.Name + retval.Location = v.EdgeWorkerFields.Location + retval.Phases = v.EdgeWorkerFields.Phases + retval.OnFailure = v.EdgeWorkerFields.OnFailure + retval.Active = v.EdgeWorkerFields.Active + retval.CreatedAt = v.EdgeWorkerFields.CreatedAt + retval.UpdatedAt = v.EdgeWorkerFields.UpdatedAt + return &retval, nil +} + // SoftwareNode includes the GraphQL fields of AppEnvironmentSoftwareSettingsSoftware requested by the fragment SoftwareNode. // The GraphQL type's documentation follows. // @@ -5744,6 +6619,120 @@ func (v *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensi return v.Message } +// UpdateEdgeWorkerResponse is returned by UpdateEdgeWorker on success. +type UpdateEdgeWorkerResponse struct { + // Update an existing WASM edge worker. + UpdateEdgeWorker *UpdateEdgeWorkerUpdateEdgeWorker `json:"updateEdgeWorker"` +} + +// GetUpdateEdgeWorker returns UpdateEdgeWorkerResponse.UpdateEdgeWorker, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerResponse) GetUpdateEdgeWorker() *UpdateEdgeWorkerUpdateEdgeWorker { + return v.UpdateEdgeWorker +} + +// UpdateEdgeWorkerUpdateEdgeWorker includes the requested fields of the GraphQL type EdgeWorker. +// The GraphQL type's documentation follows. +// +// A WASM edge worker deployed to an environment. +type UpdateEdgeWorkerUpdateEdgeWorker struct { + EdgeWorkerFields `json:"-"` +} + +// GetId returns UpdateEdgeWorkerUpdateEdgeWorker.Id, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetId() int64 { return v.EdgeWorkerFields.Id } + +// GetName returns UpdateEdgeWorkerUpdateEdgeWorker.Name, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetName() string { return v.EdgeWorkerFields.Name } + +// GetLocation returns UpdateEdgeWorkerUpdateEdgeWorker.Location, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetLocation() *EdgeWorkerFieldsLocationEdgeWorkerLocation { + return v.EdgeWorkerFields.Location +} + +// GetPhases returns UpdateEdgeWorkerUpdateEdgeWorker.Phases, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetPhases() []EdgeWorkerPhase { + return v.EdgeWorkerFields.Phases +} + +// GetOnFailure returns UpdateEdgeWorkerUpdateEdgeWorker.OnFailure, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetOnFailure() EdgeWorkerOnFailure { + return v.EdgeWorkerFields.OnFailure +} + +// GetActive returns UpdateEdgeWorkerUpdateEdgeWorker.Active, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetActive() bool { return v.EdgeWorkerFields.Active } + +// GetCreatedAt returns UpdateEdgeWorkerUpdateEdgeWorker.CreatedAt, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetCreatedAt() string { return v.EdgeWorkerFields.CreatedAt } + +// GetUpdatedAt returns UpdateEdgeWorkerUpdateEdgeWorker.UpdatedAt, and is useful for accessing the field via an interface. +func (v *UpdateEdgeWorkerUpdateEdgeWorker) GetUpdatedAt() string { return v.EdgeWorkerFields.UpdatedAt } + +func (v *UpdateEdgeWorkerUpdateEdgeWorker) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateEdgeWorkerUpdateEdgeWorker + graphql.NoUnmarshalJSON + } + firstPass.UpdateEdgeWorkerUpdateEdgeWorker = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.EdgeWorkerFields) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateEdgeWorkerUpdateEdgeWorker struct { + Id int64 `json:"id"` + + Name string `json:"name"` + + Location *EdgeWorkerFieldsLocationEdgeWorkerLocation `json:"location"` + + Phases []EdgeWorkerPhase `json:"phases"` + + OnFailure EdgeWorkerOnFailure `json:"onFailure"` + + Active bool `json:"active"` + + CreatedAt string `json:"createdAt"` + + UpdatedAt string `json:"updatedAt"` +} + +func (v *UpdateEdgeWorkerUpdateEdgeWorker) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateEdgeWorkerUpdateEdgeWorker) __premarshalJSON() (*__premarshalUpdateEdgeWorkerUpdateEdgeWorker, error) { + var retval __premarshalUpdateEdgeWorkerUpdateEdgeWorker + + retval.Id = v.EdgeWorkerFields.Id + retval.Name = v.EdgeWorkerFields.Name + retval.Location = v.EdgeWorkerFields.Location + retval.Phases = v.EdgeWorkerFields.Phases + retval.OnFailure = v.EdgeWorkerFields.OnFailure + retval.Active = v.EdgeWorkerFields.Active + retval.CreatedAt = v.EdgeWorkerFields.CreatedAt + retval.UpdatedAt = v.EdgeWorkerFields.UpdatedAt + return &retval, nil +} + // UpdateSoftwareSettingsResponse is returned by UpdateSoftwareSettings on success. type UpdateSoftwareSettingsResponse struct { // Update software settings for an application environment. @@ -6227,6 +7216,59 @@ func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeplo return v.Launched } +// Input for validating an edge worker without persisting it. +type ValidateEdgeWorkerInput struct { + // The environment to validate the worker against. + EnvironmentId int64 `json:"environmentId"` + // The base64-encoded compiled WASM binary to validate. + WasmBinary string `json:"wasmBinary"` +} + +// GetEnvironmentId returns ValidateEdgeWorkerInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *ValidateEdgeWorkerInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetWasmBinary returns ValidateEdgeWorkerInput.WasmBinary, and is useful for accessing the field via an interface. +func (v *ValidateEdgeWorkerInput) GetWasmBinary() string { return v.WasmBinary } + +// ValidateEdgeWorkerResponse is returned by ValidateEdgeWorker on success. +type ValidateEdgeWorkerResponse struct { + // Validate a WASM edge worker without persisting it, to check it before uploading. + ValidateEdgeWorker *ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult `json:"validateEdgeWorker"` +} + +// GetValidateEdgeWorker returns ValidateEdgeWorkerResponse.ValidateEdgeWorker, and is useful for accessing the field via an interface. +func (v *ValidateEdgeWorkerResponse) GetValidateEdgeWorker() *ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult { + return v.ValidateEdgeWorker +} + +// ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult includes the requested fields of the GraphQL type EdgeWorkerValidationResult. +// The GraphQL type's documentation follows. +// +// The result of validating an edge worker's WASM binary without persisting it. +type ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult struct { + // Whether the WASM binary passed validation. + Valid bool `json:"valid"` + // The request lifecycle phases the worker would run in. Empty when invalid. + Phases []EdgeWorkerPhase `json:"phases"` + // Validation error messages. Empty when the worker is valid. + Errors []string `json:"errors"` +} + +// GetValid returns ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult.Valid, and is useful for accessing the field via an interface. +func (v *ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult) GetValid() bool { + return v.Valid +} + +// GetPhases returns ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult.Phases, and is useful for accessing the field via an interface. +func (v *ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult) GetPhases() []EdgeWorkerPhase { + return v.Phases +} + +// GetErrors returns ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult.Errors, and is useful for accessing the field via an interface. +func (v *ValidateEdgeWorkerValidateEdgeWorkerEdgeWorkerValidationResult) GetErrors() []string { + return v.Errors +} + // WPEnvInfoApp includes the requested fields of the GraphQL type App. // The GraphQL type's documentation follows. // @@ -6418,6 +7460,22 @@ type __BackupDBCopyInput struct { // GetInput returns __BackupDBCopyInput.Input, and is useful for accessing the field via an interface. func (v *__BackupDBCopyInput) GetInput() *AppEnvironmentStartDBBackupCopyInput { return v.Input } +// __CreateEdgeWorkerInput is used internally by genqlient +type __CreateEdgeWorkerInput struct { + Input *edgeworkerinput.Create `json:"input,omitempty"` +} + +// GetInput returns __CreateEdgeWorkerInput.Input, and is useful for accessing the field via an interface. +func (v *__CreateEdgeWorkerInput) GetInput() *edgeworkerinput.Create { return v.Input } + +// __DeleteEdgeWorkerInput is used internally by genqlient +type __DeleteEdgeWorkerInput struct { + Input *DeleteEdgeWorkerInput `json:"input,omitempty"` +} + +// GetInput returns __DeleteEdgeWorkerInput.Input, and is useful for accessing the field via an interface. +func (v *__DeleteEdgeWorkerInput) GetInput() *DeleteEdgeWorkerInput { return v.Input } + // __DeleteEnvironmentVariableInput is used internally by genqlient type __DeleteEnvironmentVariableInput struct { Input *EnvironmentVariableInput `json:"input,omitempty"` @@ -6454,6 +7512,42 @@ func (v *__DevEnvSyncSitesInput) GetAfter() *string { return v.After } // GetFirst returns __DevEnvSyncSitesInput.First, and is useful for accessing the field via an interface. func (v *__DevEnvSyncSitesInput) GetFirst() int64 { return v.First } +// __EdgeWorkerDetailInput is used internally by genqlient +type __EdgeWorkerDetailInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __EdgeWorkerDetailInput.AppId, and is useful for accessing the field via an interface. +func (v *__EdgeWorkerDetailInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __EdgeWorkerDetailInput.EnvId, and is useful for accessing the field via an interface. +func (v *__EdgeWorkerDetailInput) GetEnvId() int64 { return v.EnvId } + +// __EdgeWorkerDetailWithSourceInput is used internally by genqlient +type __EdgeWorkerDetailWithSourceInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __EdgeWorkerDetailWithSourceInput.AppId, and is useful for accessing the field via an interface. +func (v *__EdgeWorkerDetailWithSourceInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __EdgeWorkerDetailWithSourceInput.EnvId, and is useful for accessing the field via an interface. +func (v *__EdgeWorkerDetailWithSourceInput) GetEnvId() int64 { return v.EnvId } + +// __EdgeWorkersInput is used internally by genqlient +type __EdgeWorkersInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __EdgeWorkersInput.AppId, and is useful for accessing the field via an interface. +func (v *__EdgeWorkersInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __EdgeWorkersInput.EnvId, and is useful for accessing the field via an interface. +func (v *__EdgeWorkersInput) GetEnvId() int64 { return v.EnvId } + // __EnablePhpMyAdminInput is used internally by genqlient type __EnablePhpMyAdminInput struct { Input *EnablePhpMyAdminInput `json:"input,omitempty"` @@ -6630,6 +7724,14 @@ type __ResolveAppByNameInput struct { // GetName returns __ResolveAppByNameInput.Name, and is useful for accessing the field via an interface. func (v *__ResolveAppByNameInput) GetName() string { return v.Name } +// __SetEdgeWorkerActiveInput is used internally by genqlient +type __SetEdgeWorkerActiveInput struct { + Input *SetEdgeWorkerActiveInput `json:"input,omitempty"` +} + +// GetInput returns __SetEdgeWorkerActiveInput.Input, and is useful for accessing the field via an interface. +func (v *__SetEdgeWorkerActiveInput) GetInput() *SetEdgeWorkerActiveInput { return v.Input } + // __SoftwareSettingsInput is used internally by genqlient type __SoftwareSettingsInput struct { AppId int64 `json:"appId"` @@ -6756,6 +7858,14 @@ func (v *__UpdateDefensiveModeStatusInput) GetInput() *AppEnvironmentDefensiveMo return v.Input } +// __UpdateEdgeWorkerInput is used internally by genqlient +type __UpdateEdgeWorkerInput struct { + Input *edgeworkerinput.Update `json:"input,omitempty"` +} + +// GetInput returns __UpdateEdgeWorkerInput.Input, and is useful for accessing the field via an interface. +func (v *__UpdateEdgeWorkerInput) GetInput() *edgeworkerinput.Update { return v.Input } + // __UpdateSoftwareSettingsInput is used internally by genqlient type __UpdateSoftwareSettingsInput struct { AppId int64 `json:"appId"` @@ -6786,6 +7896,14 @@ func (v *__ValidateCustomDeployAccessInput) GetInput() *ValidateCustomDeployAcce return v.Input } +// __ValidateEdgeWorkerInput is used internally by genqlient +type __ValidateEdgeWorkerInput struct { + Input *ValidateEdgeWorkerInput `json:"input,omitempty"` +} + +// GetInput returns __ValidateEdgeWorkerInput.Input, and is useful for accessing the field via an interface. +func (v *__ValidateEdgeWorkerInput) GetInput() *ValidateEdgeWorkerInput { return v.Input } + // __WPEnvInfoInput is used internally by genqlient type __WPEnvInfoInput struct { AppId int64 `json:"appId"` @@ -7274,6 +8392,85 @@ func BackupDBCopy( return data_, err_ } +// The mutation executed by CreateEdgeWorker. +const CreateEdgeWorker_Operation = ` +mutation CreateEdgeWorker ($input: CreateEdgeWorkerInput!) { + createEdgeWorker(input: $input) { + ... EdgeWorkerFields + } +} +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +} +` + +func CreateEdgeWorker( + ctx_ context.Context, + client_ graphql.Client, + input *edgeworkerinput.Create, +) (data_ *CreateEdgeWorkerResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "CreateEdgeWorker", + Query: CreateEdgeWorker_Operation, + Variables: &__CreateEdgeWorkerInput{ + Input: input, + }, + } + + data_ = &CreateEdgeWorkerResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by DeleteEdgeWorker. +const DeleteEdgeWorker_Operation = ` +mutation DeleteEdgeWorker ($input: DeleteEdgeWorkerInput!) { + deleteEdgeWorker(input: $input) +} +` + +func DeleteEdgeWorker( + ctx_ context.Context, + client_ graphql.Client, + input *DeleteEdgeWorkerInput, +) (data_ *DeleteEdgeWorkerResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DeleteEdgeWorker", + Query: DeleteEdgeWorker_Operation, + Variables: &__DeleteEdgeWorkerInput{ + Input: input, + }, + } + + data_ = &DeleteEdgeWorkerResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by DeleteEnvironmentVariable. const DeleteEnvironmentVariable_Operation = ` mutation DeleteEnvironmentVariable ($input: EnvironmentVariableInput!) { @@ -7430,6 +8627,169 @@ func DevEnvSyncSites( return data_, err_ } +// The query executed by EdgeWorkerDetail. +const EdgeWorkerDetail_Operation = ` +query EdgeWorkerDetail ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { + ... EdgeWorkerFields + } + } + } +} +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +} +` + +func EdgeWorkerDetail( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *EdgeWorkerDetailResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "EdgeWorkerDetail", + Query: EdgeWorkerDetail_Operation, + Variables: &__EdgeWorkerDetailInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &EdgeWorkerDetailResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by EdgeWorkerDetailWithSource. +const EdgeWorkerDetailWithSource_Operation = ` +query EdgeWorkerDetailWithSource ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { + ... EdgeWorkerFields + source + } + } + } +} +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +} +` + +func EdgeWorkerDetailWithSource( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *EdgeWorkerDetailWithSourceResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "EdgeWorkerDetailWithSource", + Query: EdgeWorkerDetailWithSource_Operation, + Variables: &__EdgeWorkerDetailWithSourceInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &EdgeWorkerDetailWithSourceResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by EdgeWorkers. +const EdgeWorkers_Operation = ` +query EdgeWorkers ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { + ... EdgeWorkerFields + } + } + } +} +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +} +` + +func EdgeWorkers( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *EdgeWorkersResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "EdgeWorkers", + Query: EdgeWorkers_Operation, + Variables: &__EdgeWorkersInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &EdgeWorkersResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by EnablePhpMyAdmin. const EnablePhpMyAdmin_Operation = ` mutation EnablePhpMyAdmin ($input: EnablePhpMyAdminInput!) { @@ -8190,6 +9550,53 @@ func ResolveAppByName( return data_, err_ } +// The mutation executed by SetEdgeWorkerActive. +const SetEdgeWorkerActive_Operation = ` +mutation SetEdgeWorkerActive ($input: SetEdgeWorkerActiveInput!) { + setEdgeWorkerActive(input: $input) { + ... EdgeWorkerFields + } +} +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +} +` + +func SetEdgeWorkerActive( + ctx_ context.Context, + client_ graphql.Client, + input *SetEdgeWorkerActiveInput, +) (data_ *SetEdgeWorkerActiveResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SetEdgeWorkerActive", + Query: SetEdgeWorkerActive_Operation, + Variables: &__SetEdgeWorkerActiveInput{ + Input: input, + }, + } + + data_ = &SetEdgeWorkerActiveResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by SoftwareSettings. const SoftwareSettings_Operation = ` query SoftwareSettings ($appId: Int!, $envId: Int!) { @@ -8772,6 +10179,53 @@ func UpdateDefensiveModeStatus( return data_, err_ } +// The mutation executed by UpdateEdgeWorker. +const UpdateEdgeWorker_Operation = ` +mutation UpdateEdgeWorker ($input: UpdateEdgeWorkerInput!) { + updateEdgeWorker(input: $input) { + ... EdgeWorkerFields + } +} +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { + operator + value + } + phases + onFailure + active + createdAt + updatedAt +} +` + +func UpdateEdgeWorker( + ctx_ context.Context, + client_ graphql.Client, + input *edgeworkerinput.Update, +) (data_ *UpdateEdgeWorkerResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateEdgeWorker", + Query: UpdateEdgeWorker_Operation, + Variables: &__UpdateEdgeWorkerInput{ + Input: input, + }, + } + + data_ = &UpdateEdgeWorkerResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by UpdateSoftwareSettings. const UpdateSoftwareSettings_Operation = ` mutation UpdateSoftwareSettings ($appId: Int!, $envId: Int!, $component: String!, $version: String!) { @@ -8886,6 +10340,42 @@ func ValidateCustomDeployAccess( return data_, err_ } +// The mutation executed by ValidateEdgeWorker. +const ValidateEdgeWorker_Operation = ` +mutation ValidateEdgeWorker ($input: ValidateEdgeWorkerInput!) { + validateEdgeWorker(input: $input) { + valid + phases + errors + } +} +` + +func ValidateEdgeWorker( + ctx_ context.Context, + client_ graphql.Client, + input *ValidateEdgeWorkerInput, +) (data_ *ValidateEdgeWorkerResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ValidateEdgeWorker", + Query: ValidateEdgeWorker_Operation, + Variables: &__ValidateEdgeWorkerInput{ + Input: input, + }, + } + + data_ = &ValidateEdgeWorkerResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by WPEnvInfo. const WPEnvInfo_Operation = ` query WPEnvInfo ($appId: Int!, $envId: Int!) { diff --git a/internal/gql/genqlient.yaml b/internal/gql/genqlient.yaml index e0ee0d22d..b5700903a 100644 --- a/internal/gql/genqlient.yaml +++ b/internal/gql/genqlient.yaml @@ -6,6 +6,12 @@ package: gql use_struct_references: true optional: pointer bindings: + Date: + type: string + CreateEdgeWorkerInput: + type: github.com/Automattic/vip/internal/gql/edgeworkerinput.Create + UpdateEdgeWorkerInput: + type: github.com/Automattic/vip/internal/gql/edgeworkerinput.Update Int: type: int64 ID: diff --git a/internal/gql/operations/edge_workers.graphql b/internal/gql/operations/edge_workers.graphql new file mode 100644 index 000000000..f6fba15f8 --- /dev/null +++ b/internal/gql/operations/edge_workers.graphql @@ -0,0 +1,53 @@ +fragment EdgeWorkerFields on EdgeWorker { + id + name + location { operator value } + phases + onFailure + active + createdAt + updatedAt +} + +query EdgeWorkers($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { ...EdgeWorkerFields } + } + } +} + +query EdgeWorkerDetail($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { ...EdgeWorkerFields } + } + } +} + +query EdgeWorkerDetailWithSource($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + edgeWorkers { ...EdgeWorkerFields source } + } + } +} + +mutation CreateEdgeWorker($input: CreateEdgeWorkerInput!) { + createEdgeWorker(input: $input) { ...EdgeWorkerFields } +} +mutation UpdateEdgeWorker($input: UpdateEdgeWorkerInput!) { + updateEdgeWorker(input: $input) { ...EdgeWorkerFields } +} +mutation SetEdgeWorkerActive($input: SetEdgeWorkerActiveInput!) { + setEdgeWorkerActive(input: $input) { ...EdgeWorkerFields } +} +mutation DeleteEdgeWorker($input: DeleteEdgeWorkerInput!) { + deleteEdgeWorker(input: $input) +} +mutation ValidateEdgeWorker($input: ValidateEdgeWorkerInput!) { + validateEdgeWorker(input: $input) { valid phases errors } +} diff --git a/internal/gql/schema.gql b/internal/gql/schema.gql index 723e7270a..5e13a56b0 100644 --- a/internal/gql/schema.gql +++ b/internal/gql/schema.gql @@ -478,7 +478,7 @@ type Mutation { input: AppEnvironmentCreateChildEnvironmentInput! ): AppEnvironmentCreateChildEnvironmentPayload! - """Create a new WASM edge worker on an environment.""" + """Create and upload a new inactive WASM edge worker. Use setEdgeWorkerActive to enable it after review.""" createEdgeWorker( """The edge worker to create.""" input: CreateEdgeWorkerInput! @@ -742,6 +742,12 @@ type Mutation { input: UpdateEdgeWorkerInput! ): EdgeWorker + """Validate a WASM edge worker without persisting it, to check it before uploading.""" + validateEdgeWorker( + """The edge worker binary to validate.""" + input: ValidateEdgeWorkerInput! + ): EdgeWorkerValidationResult + """Update a multisite subsite domain.""" updateEnvironmentSubsiteDomain( """The subsite domain update to apply.""" @@ -9308,4 +9314,24 @@ input LiveBackupCopyTableConfigInput { """The table-specific options.""" options: [LiveBackupCopyTableOptionConfigInput!] -} \ No newline at end of file +} + + +"Input for validating an edge worker without persisting it." +input ValidateEdgeWorkerInput { + "The environment to validate the worker against." + environmentId: Int! + "The base64-encoded compiled WASM binary to validate." + wasmBinary: String! +} + + +"The result of validating an edge worker's WASM binary without persisting it." +type EdgeWorkerValidationResult { + "Whether the WASM binary passed validation." + valid: Boolean! + "The request lifecycle phases the worker would run in. Empty when invalid." + phases: [EdgeWorkerPhase!]! + "Validation error messages. Empty when the worker is valid." + errors: [String!]! +} diff --git a/internal/output/json.go b/internal/output/json.go index 8ab0329b8..1b019d331 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -6,6 +6,7 @@ import ( json "encoding/json/v2" "fmt" "io" + "strings" ) // renderJSON writes data as tab-indented JSON via encoding/json/v2. @@ -23,12 +24,13 @@ import ( // encoding/json/v2 would alphabetize map keys, matching Node's // JSON.stringify(arrayOfObjects) insertion-order behavior. func renderJSON(w io.Writer, data any) error { + var encoded bytes.Buffer switch v := data.(type) { case HeaderData: // Node parity: drop header in JSON mode; emit only the data payload. return renderJSON(w, v.Data) case OrderedRows: - if err := writeOrderedRowsJSON(w, v); err != nil { + if err := writeOrderedRowsJSON(&encoded, v); err != nil { return err } default: @@ -36,11 +38,22 @@ func renderJSON(w io.Writer, data any) error { json.Deterministic(true), jsontext.WithIndent("\t"), } - if err := json.MarshalWrite(w, data, opts...); err != nil { + if err := json.MarshalWrite(&encoded, data, opts...); err != nil { return err } } - _, err := io.WriteString(w, "\n") + // Node's formatData escapes DEL/C1 after JSON.stringify. Preserve the + // decoded values while preventing terminal control bytes in JSON output. + var safe strings.Builder + for _, r := range encoded.String() { + if r >= 0x7f && r <= 0x9f { + fmt.Fprintf(&safe, `\u%04x`, r) + } else { + safe.WriteRune(r) + } + } + safe.WriteByte('\n') + _, err := io.WriteString(w, safe.String()) return err } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index bfe6d7a23..b55b3b9fe 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -2,10 +2,28 @@ package output import ( "bytes" + json "encoding/json/v2" "strings" "testing" ) +func TestRenderJSONControlCharacters(t *testing.T) { + value := "safe\u007f\u009b[31m" + for _, data := range []any{Rows{{"value": value}}, OrderedRows{{{Key: "value", Value: value}}}} { + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, data); err != nil { + t.Fatal(err) + } + if strings.ContainsAny(buf.String(), "\u007f\u009b") || !strings.Contains(buf.String(), `safe\u007f\u009b[31m`) { + t.Fatalf("control bytes: %q", buf.String()) + } + var decoded []map[string]string + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil || decoded[0]["value"] != value { + t.Fatalf("decoded %v (%v)", decoded, err) + } + } +} + func TestRenderJSONRows(t *testing.T) { data := Rows{ {"id": 1, "name": "alpha"}, diff --git a/internal/parity/edge_workers_build_smoke_test.go b/internal/parity/edge_workers_build_smoke_test.go new file mode 100644 index 000000000..5b1da3f57 --- /dev/null +++ b/internal/parity/edge_workers_build_smoke_test.go @@ -0,0 +1,69 @@ +//go:build parity && edgeworkers_build_smoke + +package parity + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "testing" +) + +// Opt-in only: downloads the pinned SDK/compiler into owned temporary projects. +func TestEdgeWorkersRealCompilerSmoke(t *testing.T) { + rig, skip := differentialAvailable(t) + if skip != "" { + t.Skip(LoudSkip("Edge Workers real compiler smoke", skip)) + } + api := newEdgeFixtureAPI("empty") + rig.serve(t, api) + env := FixtureEnv(rig.scenarioEnv(&Scenario{Env: map[string]string{"NO_COLOR": "1", "VIP_NON_INTERACTIVE": "1"}})) + roots := []string{edgeFixtureProject(t, "empty"), edgeFixtureProject(t, "empty")} + trees := make([]map[string]string, 2) + artifacts := make([][]byte, 2) + run := func(bin, dir string, args ...string) { + t.Helper() + result, err := Run(RunSpec{Binary: bin, Dir: dir, Argv: args, Env: env}) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 { + t.Fatalf("%s %v: %s\n%s", bin, args, result.Stdout, result.Stderr) + } + } + for i, bin := range []string{rig.nodeBin, rig.goBin} { + run(bin, roots[i], "edge-workers", "init", ".") + run(bin, roots[i], "edge-workers", "new", "headers") + trees[i] = edgeTree(t, roots[i]) + if i == 0 { + run("npm", roots[i], "install", "--no-audit", "--no-fund") + } else { + lock, err := os.ReadFile(filepath.Join(roots[0], "package-lock.json")) + if err != nil { + t.Fatal(err) + } + edgeWrite(t, roots[i], "package-lock.json", lock, 0644) + run("npm", roots[i], "ci", "--no-audit", "--no-fund") + } + run(bin, roots[i], "edge-workers", "build", "headers") + file := filepath.Join(roots[i], "build/headers.wasm") + artifact, err := os.ReadFile(file) + if err != nil || len(artifact) == 0 { + t.Fatalf("artifact %v", err) + } + artifacts[i] = artifact + run("node", roots[i], "-e", `const fs=require('node:fs');const m=new WebAssembly.Module(fs.readFileSync(process.argv[1]));const names=WebAssembly.Module.exports(m).filter(x=>x.kind==='function').map(x=>x.name).sort();if(JSON.stringify(names)!==JSON.stringify(['alloc','on_client_response']))throw Error('Unexpected exports: '+JSON.stringify(names));`, file) + t.Logf("runtime %d compiled %d bytes with only alloc/on_client_response exports", i, len(artifact)) + } + if !reflect.DeepEqual(trees[0], trees[1]) { + t.Fatal("real scaffold files differ") + } + if !bytes.Equal(artifacts[0], artifacts[1]) { + t.Fatal("real compiler artifacts differ") + } + ops, requests := api.snapshot() + if len(ops) != 0 || len(requests) != 0 { + t.Fatalf("local build issued API requests: %v", ops) + } +} diff --git a/internal/parity/edge_workers_cases_test.go b/internal/parity/edge_workers_cases_test.go new file mode 100644 index 000000000..4a03aad2b --- /dev/null +++ b/internal/parity/edge_workers_cases_test.go @@ -0,0 +1,77 @@ +//go:build parity + +package parity + +// Each case runs both real roots; expected writes are asserted independently. +var edgeWorkerScenarios = map[string]edgeWorkerCase{ + "edge-workers-init": {Fixture: "empty", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-init-nonempty": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-init-invalid-type": {Fixture: "empty", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-new": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-new-location": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-new-invalid-location": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-new-invalid-name": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-build-missing-compiler": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-build-empty": {Fixture: "no-workers", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-build-name-all": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-build-one": {Fixture: "compiler", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-build-all": {Fixture: "two-compiler", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-build-compiler-error": {Fixture: "compiler-error", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-list-table": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-list-csv": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-list-json": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-list-empty": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-list-empty-json": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-get": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-get-source": {Fixture: "basic", State: "formatted-source", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-get-empty-source": {Fixture: "basic", State: "empty-source", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-get-missing": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-read-null": {Fixture: "basic", State: "null-read", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-validate": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-validate-invalid": {Fixture: "basic", State: "invalid-validation", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-validate-all": {Fixture: "two-workers", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-validate-name-all": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-validate-no-name": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-validate-no-artifact": {Fixture: "no-artifact", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-validate-null": {Fixture: "basic", State: "null-validation", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-deploy-create": {Fixture: "basic", State: "empty", WantPersistentOps: []string{"create:headers"}, WantExit: 0}, + "edge-workers-deploy-create-enable": {Fixture: "basic", State: "empty", WantPersistentOps: []string{"create:headers", "enable:headers"}, WantExit: 0}, + "edge-workers-deploy-inactive-update": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-inactive-update-enable": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{"update:headers", "enable:headers"}, WantExit: 0}, + "edge-workers-deploy-active-update": {Fixture: "basic", State: "active", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-active-update-enable": {Fixture: "basic", State: "active", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-location-preserve": {Fixture: "location-preserve", State: "inactive", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-location-clear": {Fixture: "location-clear", State: "inactive", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-location-replace": {Fixture: "location-replace", State: "inactive", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-source-empty": {Fixture: "source-empty", State: "empty", WantPersistentOps: []string{"create:headers"}, WantExit: 0}, + "edge-workers-deploy-skip-source": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{"update:headers"}, WantExit: 0}, + "edge-workers-deploy-all-preparation-failure": {Fixture: "two-workers", State: "invalid-second", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-deploy-upload-failure": {Fixture: "two-workers", State: "upload-second-fails", WantPersistentOps: []string{"create:a", "create:b"}, WantExit: 1}, + "edge-workers-deploy-enable-failure": {Fixture: "two-workers", State: "enable-fails", WantPersistentOps: []string{"create:a", "enable:a"}, WantExit: 1}, + "edge-workers-deploy-production-refused": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-deploy-name-all": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-enable": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{"enable:headers"}, WantExit: 0}, + "edge-workers-enable-production-refused": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-disable": {Fixture: "basic", State: "active", WantPersistentOps: []string{"disable:headers"}, WantExit: 0}, + "edge-workers-delete-force": {Fixture: "basic", State: "inactive", WantPersistentOps: []string{"delete:headers"}, WantExit: 0}, + "edge-workers-delete-rejected": {Fixture: "basic", State: "false-delete", WantPersistentOps: []string{"delete:headers"}, WantExit: 1}, + "edge-workers-mutation-null": {Fixture: "basic", State: "null-mutation", WantPersistentOps: []string{"enable:headers"}, WantExit: 1}, + "edge-workers-mutation-graphql-error": {Fixture: "basic", State: "graphql-error", WantPersistentOps: []string{"enable:headers"}, WantExit: 1}, + "edge-workers-bare-path": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-bare-location": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-short-equals": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-double-dash": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-duplicate-names": {Fixture: "duplicates", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-entry-escape": {Fixture: "entry-escape", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-artifact-symlink": {Fixture: "artifact-symlink", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-repeated-path": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-repeated-type": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-bare-type": {Fixture: "basic", State: "empty", WantPersistentOps: []string{}, WantExit: 1}, + "edge-workers-unicode-order": {Fixture: "unicode-compiler", State: "empty", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-json-control-name": {Fixture: "basic", State: "control-name", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-table-control-name": {Fixture: "basic", State: "control-name", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-get-no-source": {Fixture: "basic", State: "no-source", WantPersistentOps: []string{}, WantExit: 0}, + "edge-workers-deploy-production-skip": {Fixture: "basic", State: "empty", WantPersistentOps: []string{"create:headers"}, WantExit: 0}, + "edge-workers-deploy-skip-validate": {Fixture: "basic", State: "empty", WantPersistentOps: []string{"create:headers"}, WantExit: 0}, + "edge-workers-deploy-skip-source-create": {Fixture: "basic", State: "empty", WantPersistentOps: []string{"create:headers"}, WantExit: 0}, +} diff --git a/internal/parity/edge_workers_differential_test.go b/internal/parity/edge_workers_differential_test.go new file mode 100644 index 000000000..314df8e28 --- /dev/null +++ b/internal/parity/edge_workers_differential_test.go @@ -0,0 +1,483 @@ +//go:build parity + +package parity + +import ( + "encoding/base64" + json "encoding/json/v2" + "io/fs" + "net/http" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/parser" +) + +type edgeWorkerCase struct { + Fixture, State string + WantPersistentOps []string + WantExit int +} +type edgeRequest struct { + Operation string `json:"operationName"` + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} +type edgeObservation struct { + Operation string + Variables map[string]any + Source bool + Fields []string +} + +// Compare selected fields across generated fragments and Node's inline query. +// Ignore only Apollo's __typename bookkeeping, never source or input values. +func edgeSelectedFields(query string) ([]string, error) { + doc, err := parser.ParseQuery(&ast.Source{Input: query}) + if err != nil { + return nil, err + } + fields := []string{} + var walk func(ast.SelectionSet, string) + walk = func(selections ast.SelectionSet, prefix string) { + for _, selection := range selections { + switch s := selection.(type) { + case *ast.Field: + if s.Name == "__typename" { + continue + } + path := prefix + s.Name + fields = append(fields, path) + walk(s.SelectionSet, path+".") + case *ast.FragmentSpread: + if fragment := doc.Fragments.ForName(s.Name); fragment != nil { + walk(fragment.SelectionSet, prefix) + } + case *ast.InlineFragment: + walk(s.SelectionSet, prefix) + } + } + } + for _, operation := range doc.Operations { + walk(operation.SelectionSet, "") + } + sort.Strings(fields) + return fields, nil +} + +type edgeFixtureAPI struct { + mu sync.Mutex + ops []string + requests []edgeObservation + workers []map[string]any + validations int + state string +} + +func edgeRemoteWorker(name string, active bool) map[string]any { + return map[string]any{"id": 9, "name": name, "active": active, "location": map[string]any{"operator": "contains", "value": "/old"}, "phases": []string{"client_response"}, "onFailure": "continue", "createdAt": "2026-08-28T00:00:00.000Z", "updatedAt": "2026-08-28T01:00:00.000Z", "source": "// stored source\n"} +} +func newEdgeFixtureAPI(state string) *edgeFixtureAPI { + a := &edgeFixtureAPI{state: state, ops: []string{}, requests: []edgeObservation{}, workers: []map[string]any{}} + switch state { + case "inactive", "active", "empty-source", "no-source", "formatted-source", "null-mutation", "false-delete", "graphql-error", "control-name": + w := edgeRemoteWorker("headers", state == "active") + if state == "formatted-source" { + w["source"] = "// café\nexport function run(): void {\r\n\t// controls: \x1b[2J\x00\b\r\x7f\u0085\u009b31m\n}\n// literal: \\u000a\n" + } + if state == "empty-source" { + w["source"] = "" + } + if state == "no-source" { + w["source"] = nil + } + if state == "control-name" { + w["name"] = "headers\x1b\n\u009b" + } + a.workers = append(a.workers, w) + } + return a +} +func (a *edgeFixtureAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + var req edgeRequest + if err := json.UnmarshalRead(r.Body, &req); err != nil { + http.Error(w, err.Error(), 400) + return + } + reply := func(data any) { _ = json.MarshalWrite(w, map[string]any{"data": data}) } + fail := func(message string) { + _ = json.MarshalWrite(w, map[string]any{"errors": []map[string]any{{"message": message}}}) + } + if req.Operation == "App" || req.Operation == "ResolveAppByID" || req.Operation == "ResolveAppByName" { + envs := []map[string]any{{"id": 7, "appId": 7, "name": "develop", "type": "develop", "primaryDomain": nil}, {"id": 8, "appId": 8, "name": "production", "type": "production", "primaryDomain": nil}} + reply(map[string]any{"app": map[string]any{"id": 42, "name": "example-app", "environments": envs}}) + return + } + operation := req.Operation + if operation == "EdgeWorkerDetailWithSource" { + operation = "EdgeWorkerDetail" + } + fields, err := edgeSelectedFields(req.Query) + if err != nil { + fail("invalid GraphQL query") + return + } + source := false + for _, field := range fields { + if field == "app.environments.edgeWorkers.source" { + source = true + } + } + a.requests = append(a.requests, edgeObservation{operation, req.Variables, source, fields}) + switch operation { + case "EdgeWorkers", "EdgeWorkerDetail": + if a.state == "null-read" { + reply(nil) + return + } + // Source/binary are never needed for list or reconciliation. + if strings.Contains(req.Query, "wasmBinary") { + fail("unexpected binary selection") + return + } + if operation == "EdgeWorkers" && source { + fail("unexpected source selection") + return + } + reply(map[string]any{"app": map[string]any{"environments": []map[string]any{{"id": req.Variables["envId"], "edgeWorkers": a.workers}}}}) + case "ValidateEdgeWorker": + a.validations++ + if a.state == "null-validation" { + reply(map[string]any{"validateEdgeWorker": nil}) + return + } + valid := a.state != "invalid-validation" && !(a.state == "invalid-second" && a.validations == 2) + errs := []string{} + if !valid { + errs = []string{"bad wasm"} + } + reply(map[string]any{"validateEdgeWorker": map[string]any{"valid": valid, "phases": []string{"client_response"}, "errors": errs}}) + case "CreateEdgeWorker", "UpdateEdgeWorker", "SetEdgeWorkerActive", "DeleteEdgeWorker": + input, _ := req.Variables["input"].(map[string]any) + name, _ := input["name"].(string) + if name == "" && len(a.workers) > 0 { + name, _ = a.workers[len(a.workers)-1]["name"].(string) + } + kind := map[string]string{"CreateEdgeWorker": "create", "UpdateEdgeWorker": "update", "DeleteEdgeWorker": "delete", "SetEdgeWorkerActive": "enable"}[operation] + if operation == "SetEdgeWorkerActive" && input["active"] == false { + kind = "disable" + } + a.ops = append(a.ops, kind+":"+name) + field := strings.ToLower(operation[:1]) + operation[1:] + if a.state == "null-mutation" { + reply(map[string]any{field: nil}) + return + } + if a.state == "graphql-error" || a.state == "upload-second-fails" && name == "b" || a.state == "enable-fails" && kind == "enable" { + fail("fixture rejected request") + return + } + if kind == "delete" { + reply(map[string]any{field: a.state != "false-delete"}) + return + } + worker := edgeRemoteWorker(name, false) + if operation != "CreateEdgeWorker" && len(a.workers) > 0 { + worker = a.workers[len(a.workers)-1] + } + if operation == "CreateEdgeWorker" { + worker["location"] = nil + } + for _, key := range []string{"name", "location", "source", "onFailure", "active"} { + if value, ok := input[key]; ok { + worker[key] = value + } + } + a.workers = append(a.workers, worker) + reply(map[string]any{field: worker}) + default: + fail("unexpected operation: " + req.Operation) + } +} +func (a *edgeFixtureAPI) snapshot() ([]string, []edgeObservation) { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string{}, a.ops...), append([]edgeObservation{}, a.requests...) +} + +func edgeWrite(t *testing.T, dir, name string, data []byte, mode fs.FileMode) { + t.Helper() + file := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, data, mode); err != nil { + t.Fatal(err) + } +} +func edgeFixtureProject(t *testing.T, fixture string) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if fixture == "empty" { + return dir + } + base := "basic" + if strings.HasPrefix(fixture, "two") { + base = "two-workers" + } + source := filepath.Join("../../testdata/parity-local/edge-workers", base) + if err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + edgeWrite(t, dir, rel, data, 0644) + return nil + }); err != nil { + t.Fatal(err) + } + names := []string{"headers"} + if base == "two-workers" { + names = []string{"a", "b"} + } + if fixture == "no-workers" { + if err := os.RemoveAll(filepath.Join(dir, "workers")); err != nil { + t.Fatal(err) + } + names = nil + } + if fixture == "unicode-compiler" { + names = []string{"headers", "ä", "Alpha", "zebra", "Álpha", "é"} + for _, name := range names[1:] { + manifest, _ := json.Marshal(map[string]any{"name": name, "entry": "assembly/index.ts"}, json.Deterministic(true)) + edgeWrite(t, dir, "workers/"+name+"/worker.json", manifest, 0644) + edgeWrite(t, dir, "workers/"+name+"/assembly/index.ts", []byte("// fixture source\n"), 0644) + } + } + wasm := []byte{0, 97, 115, 109, 1, 0, 0, 0} + if fixture != "no-artifact" { + for _, name := range names { + edgeWrite(t, dir, "build/"+name+".wasm", wasm, 0644) + } + } + switch fixture { + case "location-clear", "location-replace", "entry-escape": + manifest := map[string]any{"name": "headers", "entry": "assembly/index.ts"} + if fixture == "location-clear" { + manifest["location"] = nil + } + if fixture == "location-replace" { + manifest["location"] = map[string]any{"operator": "starts_with", "value": "/api/"} + } + if fixture == "entry-escape" { + manifest["entry"] = "../../../escape.ts" + } + data, _ := json.Marshal(manifest, json.Deterministic(true)) + edgeWrite(t, dir, "workers/headers/worker.json", data, 0644) + case "source-empty": + edgeWrite(t, dir, "workers/headers/assembly/index.ts", nil, 0644) + case "duplicates": + edgeWrite(t, dir, "workers/duplicate/worker.json", []byte(`{"name":"HEADERS","entry":"assembly/index.ts"}`), 0644) + case "artifact-symlink": + target := filepath.Join(dir, "build/headers.wasm") + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(outside, wasm, 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, target); err != nil { + t.Fatal(err) + } + } + if strings.Contains(fixture, "compiler") { + script := "#!/usr/bin/env node\nconst fs=require('node:fs'); const args=process.argv.slice(2); fs.writeFileSync(args[args.indexOf('--outFile')+1],Buffer.from('AGFzbQEAAAA=','base64'));\n" + if fixture == "compiler-error" { + script = "#!/usr/bin/env node\nprocess.stderr.write('fixture compiler error\\n');process.exit(1);\n" + } + edgeWrite(t, dir, "node_modules/.bin/asc", []byte(script), 0755) + } + return dir +} +func edgeTree(t *testing.T, dir string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(dir, func(path string, e fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, path) + if e.IsDir() { + if e.Name() == "node_modules" { + return filepath.SkipDir + } + return nil + } + if e.Type()&os.ModeSymlink != 0 { + out[rel] = "" + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + out[rel] = base64.StdEncoding.EncodeToString(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestEdgeWorkersDifferentialParity(t *testing.T) { + rig, skip := differentialAvailable(t) + if skip != "" { + t.Skip(LoudSkip("Edge Workers real Node-vs-Go comparisons", skip)) + } + names := make([]string, 0, len(edgeWorkerScenarios)) + for name := range edgeWorkerScenarios { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + t.Run(name, func(t *testing.T) { + tc := edgeWorkerScenarios[name] + s, err := LoadScenario("../../testdata/parity/" + name + ".yaml") + if err != nil { + t.Fatal(err) + } + if s.ExpectedDrift != nil { + t.Fatal("Edge Workers must not broaden accepted drift") + } + // Preserve the existing cutover contract, docs/CUTOVER-BREAKING-CHANGES.md + // section 1.10 for the known runtime banner and error-prefix spacing. + // The approved executable-name difference is checked separately below. + s.Normalize.Stdout = []NormalizeRule{{Pattern: `(?m)^Debug: VIP-CLI v[^,\n]+, Node v[^,\n]+, [^,\n]+, Runtime node-script\n`, Replacement: ""}} + s.Normalize.Stderr = []NormalizeRule{{Pattern: `(?m)^Error: `, Replacement: "Error: "}} + results := make([]*RunResult, 2) + trees := make([]map[string]string, 2) + requests := make([][]edgeObservation, 2) + for side, bin := range []string{rig.nodeBin, rig.goBin} { + dir := edgeFixtureProject(t, tc.Fixture) + api := newEdgeFixtureAPI(tc.State) + rig.serve(t, api) + args := make([]string, len(s.Argv)) + for i, arg := range s.Argv { + args[i] = strings.ReplaceAll(arg, "PROJECT_DIR", dir) + } + result, err := Run(RunSpec{Binary: bin, Dir: dir, Argv: args, Env: FixtureEnv(rig.scenarioEnv(s))}) + if err != nil { + t.Fatal(err) + } + result.Stdout = strings.ReplaceAll(result.Stdout, dir, "PROJECT_DIR") + result.Stderr = strings.ReplaceAll(result.Stderr, dir, "PROJECT_DIR") + // Each runtime's init/new guidance must invoke that runtime. Only + // normalize these known prefixes after checking the actual output. + // See CUTOVER-BREAKING-CHANGES.md section 1.28. + guidance := "" + if result.ExitCode == 0 && s.Argv[1] == "init" { + guidance = "edge-workers new my-worker\n" + } else if result.ExitCode == 0 && s.Argv[1] == "new" { + guidance = "@my-site.develop edge-workers deploy " + } + if guidance != "" { + executable := "vip" + if side == 1 { + executable = "vip-next" + } + prefix := "\n " + executable + " " + guidance + if !strings.Contains(result.Stdout, prefix) { + t.Errorf("side %d missing runtime-specific guidance %q: %s", side, prefix, result.Stdout) + } + if side == 1 { + result.Stdout = strings.Replace(result.Stdout, prefix, "\n vip "+guidance, 1) + } + } + results[side] = result + if name == "edge-workers-get-source" { + // Check readability independently: matching runtimes can share a bug. + _, source, found := strings.Cut(result.Stdout, "\nSource:\n") + want := "// café\nexport function run(): void {\n\t// controls: \\u001b[2J\\u0000\\u0008\\u000d\\u007f\\u0085\\u009b31m\n}\n// literal: \\u000a\n\n" + if !found || source != want { + t.Errorf("side %d source = %q, want %q", side, source, want) + } + } + ops, observations := api.snapshot() + for _, observation := range observations { + if observation.Operation == "EdgeWorkerDetail" { + wantSource := false + for _, arg := range s.Argv { + if arg == "--source" { + wantSource = true + } + } + if observation.Source != wantSource { + t.Errorf("side %d source selection = %v want %v", side, observation.Source, wantSource) + } + } + } + requests[side] = observations + trees[side] = edgeTree(t, dir) + if result.ExitCode != tc.WantExit || result.ExitCode != s.Expect.ExitCode { + t.Errorf("side %d exit %d, want %d: stdout=%s stderr=%s", side, result.ExitCode, tc.WantExit, result.Stdout, result.Stderr) + } + if !reflect.DeepEqual(ops, tc.WantPersistentOps) { + t.Errorf("side %d persistent operations %v, want %v", side, ops, tc.WantPersistentOps) + } + } + diff, err := Diff(s, results[0], results[1]) + if err != nil { + t.Fatal(err) + } + if !diff.Equal { + t.Errorf("%s\n%s\n%s", diff.ExitCodeDelta, diff.StdoutDelta, diff.StderrDelta) + } + if !reflect.DeepEqual(trees[0], trees[1]) { + t.Error("filesystem effects differ between Node and Go") + for path, data := range trees[0] { + if trees[1][path] != data { + t.Logf("file differs: %s", path) + } + } + } + if !reflect.DeepEqual(requests[0], requests[1]) { + t.Errorf("semantic API requests differ:\nNode: %#v\nGo: %#v", requests[0], requests[1]) + } + }) + } +} + +func TestEdgeWorkersScenarioInventory(t *testing.T) { + for name := range edgeWorkerScenarios { + if _, err := LoadScenario("../../testdata/parity/" + name + ".yaml"); err != nil { + t.Error(err) + } + if _, ok := surfaceDifferentialScenarios[name]; ok { + t.Errorf("duplicate ownership: %s", name) + } + } +} diff --git a/internal/parity/edge_workers_prompt_test.go b/internal/parity/edge_workers_prompt_test.go new file mode 100644 index 000000000..7e0c39b1b --- /dev/null +++ b/internal/parity/edge_workers_prompt_test.go @@ -0,0 +1,151 @@ +//go:build parity && !windows + +package parity + +import ( + "bytes" + "context" + "errors" + "io" + "os/exec" + "reflect" + "strings" + "syscall" + "testing" + "time" + + "github.com/creack/pty" +) + +func runEdgePrompt(t *testing.T, bin, dir string, args, env []string, prompt, answer string, redirect bool) (string, int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = dir + cmd.Env = env + // The PTY owns its session; on timeout also stop the Node dispatch child. + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + var stdout bytes.Buffer + if redirect { + cmd.Stdout = &stdout + } + master, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 160}) + if err != nil { + t.Fatal(err) + } + defer master.Close() + chunks := make(chan string, 128) + readDone := make(chan struct{}) + go func() { + defer close(readDone) + buffer := make([]byte, 4096) + for { + n, err := master.Read(buffer) + if n > 0 { + chunks <- string(buffer[:n]) + } + if err != nil { + return + } + } + }() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var transcript strings.Builder + answered := false + queries := 0 + var answerTimer <-chan time.Time + for { + select { + case chunk := <-chunks: + transcript.WriteString(chunk) + // Survey asks the terminal for its cursor position before reading + // input. A PTY is only a byte stream, so emulate the terminal reply. + for count := strings.Count(transcript.String(), "[6n"); queries < count; queries++ { + _, _ = io.WriteString(master, "\x1b[24;80R") + } + if prompt != "" && !answered && answerTimer == nil && strings.Contains(transcript.String(), prompt) { + answerTimer = time.After(100 * time.Millisecond) + } + case <-answerTimer: + answered = true + answerTimer = nil + if _, err := io.WriteString(master, answer+"\r"); err != nil { + t.Fatal(err) + } + case err := <-done: + master.Close() + <-readDone + for len(chunks) > 0 { + transcript.WriteString(<-chunks) + } + if ctx.Err() != nil { + t.Fatalf("prompt timed out; answered=%v transcript=%s", answered, transcript.String()) + } + if prompt != "" && !answered { + t.Fatalf("missing prompt %q: %s", prompt, transcript.String()) + } + code := 0 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + code = exitErr.ExitCode() + } else if err != nil { + t.Fatal(err) + } + return stdout.String() + transcript.String(), code + } + } +} + +func TestEdgeWorkersPromptParity(t *testing.T) { + rig, skip := differentialAvailable(t) + if skip != "" { + t.Skip(LoudSkip("Edge Workers terminal prompts", skip)) + } + for _, tc := range []struct { + name, action, answer, state, fixture, prompt string + redirect bool + want int + ops []string + }{ + {"deploy-approve", "deploy", "y", "empty", "two-workers", "Deploy 2 edge workers (a, b) to example-app.production?", false, 0, []string{"create:a", "create:b"}}, + {"deploy-decline", "deploy", "n", "empty", "two-workers", "Deploy 2 edge workers (a, b) to example-app.production?", false, 1, []string{}}, + {"enable-decline", "enable", "n", "inactive", "basic", `Enable edge worker "headers" on example-app.production?`, false, 1, []string{}}, + {"delete-approve", "delete", "y", "inactive", "basic", `Permanently delete edge worker "headers" from example-app.production?`, false, 0, []string{"delete:headers"}}, + {"delete-decline", "delete", "n", "inactive", "basic", `Permanently delete edge worker "headers" from example-app.production?`, false, 1, []string{}}, + {"stdout-redirect", "deploy", "", "empty", "basic", "", true, 1, []string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, bin := range []string{rig.nodeBin, rig.goBin} { + dir := edgeFixtureProject(t, tc.fixture) + api := newEdgeFixtureAPI(tc.state) + rig.serve(t, api) + args := []string{"edge-workers", tc.action, "headers", "--app=42", "--env=production"} + if tc.action == "deploy" { + args = append(args, "--skip-build") + if tc.fixture == "two-workers" { + args[2] = "--all" + } + } + env := FixtureEnv(rig.scenarioEnv(&Scenario{Env: map[string]string{"NO_COLOR": "1", "TERM": "xterm", "VIP_NON_INTERACTIVE": "0"}})) + transcript, code := runEdgePrompt(t, bin, dir, args, env, tc.prompt, tc.answer, tc.redirect) + if code != tc.want { + t.Errorf("exit %d want %d: %s", code, tc.want, transcript) + } + ops, _ := api.snapshot() + if !reflect.DeepEqual(ops, tc.ops) { + t.Errorf("operations %v want %v", ops, tc.ops) + } + if tc.redirect && !strings.Contains(transcript, "Refusing to deploy") { + t.Errorf("missing redirected-output refusal: %s", transcript) + } + } + }) + } +} diff --git a/internal/parity/runner.go b/internal/parity/runner.go index 5d2db1b9e..1e5f858fb 100644 --- a/internal/parity/runner.go +++ b/internal/parity/runner.go @@ -10,6 +10,7 @@ import ( type RunSpec struct { Binary string + Dir string // empty inherits the caller's working directory Argv []string Env []string // KEY=VALUE Stdin []byte @@ -27,6 +28,7 @@ type RunResult struct { // (binary not found, etc.) are returned as errors. func Run(spec RunSpec) (*RunResult, error) { cmd := exec.Command(spec.Binary, spec.Argv...) + cmd.Dir = spec.Dir cmd.Env = spec.Env if len(spec.Stdin) > 0 { cmd.Stdin = bytes.NewReader(spec.Stdin) diff --git a/internal/parity/runner_test.go b/internal/parity/runner_test.go index 6a33f4c37..3d4e1ffff 100644 --- a/internal/parity/runner_test.go +++ b/internal/parity/runner_test.go @@ -3,10 +3,34 @@ package parity import ( + "os" + "path/filepath" "strings" "testing" ) +func TestRunSetsWorkingDirectory(t *testing.T) { + dir := t.TempDir() + // Do not spawn this test binary as a helper: its TestMain credential sweep + // would delete the parent process's live differential credential. + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/parity-cwd\n"), 0600); err != nil { + t.Fatal(err) + } + canonical, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(canonical, "go.mod") + result, err := Run(RunSpec{Binary: "go", Dir: dir, Argv: []string{"env", "GOMOD"}, Env: FixtureEnv(nil)}) + if err != nil { + t.Fatal(err) + } + got, err := filepath.EvalSymlinks(strings.TrimSpace(result.Stdout)) + if err != nil || result.ExitCode != 0 || got != want { + t.Fatalf("result %#v error %v", result, err) + } +} + func TestRunCapturesStdoutAndExit(t *testing.T) { // Use `go env GOVERSION` as a trivially available command that prints // to stdout and exits 0 on every supported platform. diff --git a/internal/parity/surface_differential_test.go b/internal/parity/surface_differential_test.go index e94c94126..57f497983 100644 --- a/internal/parity/surface_differential_test.go +++ b/internal/parity/surface_differential_test.go @@ -452,6 +452,9 @@ func TestEverySurfaceScenarioIsClassified(t *testing.T) { if isM5Scenario(base) { continue // m5_differential_test.go owns these } + if _, ok := edgeWorkerScenarios[base]; ok { + continue // edge_workers_differential_test.go runs both roots + } seen++ if _, ok := surfaceDifferentialScenarios[base]; ok { continue diff --git a/src/bin/vip-edge-workers-get.js b/src/bin/vip-edge-workers-get.js index bab27b15f..2127c9150 100644 --- a/src/bin/vip-edge-workers-get.js +++ b/src/bin/vip-edge-workers-get.js @@ -4,7 +4,7 @@ import { appQuery, getEdgeWorker } from '../lib/api/edge-workers'; import command from '../lib/cli/command'; import * as exit from '../lib/cli/exit'; import { keyValue } from '../lib/cli/format'; -import { escapeTerminalText } from '../lib/edge-workers/output'; +import { escapeTerminalSource, escapeTerminalText } from '../lib/edge-workers/output'; import { trackEventWithEnv } from '../lib/tracker'; const usage = 'vip edge-workers get'; @@ -78,7 +78,7 @@ export async function edgeWorkersGetCommand( args = [], opt = {} ) { console.log( worker.source === null || worker.source === undefined ? '(no source stored)' - : escapeTerminalText( worker.source ) + : escapeTerminalSource( worker.source ) ); } } diff --git a/src/lib/edge-workers/output.ts b/src/lib/edge-workers/output.ts index 0d179709f..8a70caae5 100644 --- a/src/lib/edge-workers/output.ts +++ b/src/lib/edge-workers/output.ts @@ -3,6 +3,9 @@ const TERMINAL_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; // eslint-disable-next-line no-control-regex const TERMINAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g; +// Source may contain horizontal tabs and line feeds, but no other terminal controls. +// eslint-disable-next-line no-control-regex +const SOURCE_CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g; function escapeControlCharacter( character: string ): string { const codePoint = character.codePointAt( 0 ); @@ -20,3 +23,10 @@ export function hasTerminalControlCharacters( value: string ): boolean { export function escapeTerminalText( value: unknown ): string { return String( value ).replace( TERMINAL_CONTROL_CHARACTERS, escapeControlCharacter ); } + +/** Preserve source layout; normalize CRLF while escaping standalone carriage returns. */ +export function escapeTerminalSource( value: string ): string { + return value + .replace( /\r\n/g, '\n' ) + .replace( SOURCE_CONTROL_CHARACTERS, escapeControlCharacter ); +} diff --git a/testdata/parity-local/edge-workers/basic/edge-workers.json b/testdata/parity-local/edge-workers/basic/edge-workers.json new file mode 100644 index 000000000..7837a5bd5 --- /dev/null +++ b/testdata/parity-local/edge-workers/basic/edge-workers.json @@ -0,0 +1 @@ +{"type":"assemblyscript"} diff --git a/testdata/parity-local/edge-workers/basic/workers/headers/assembly/index.ts b/testdata/parity-local/edge-workers/basic/workers/headers/assembly/index.ts new file mode 100644 index 000000000..a3be17d2d --- /dev/null +++ b/testdata/parity-local/edge-workers/basic/workers/headers/assembly/index.ts @@ -0,0 +1 @@ +// fixture source diff --git a/testdata/parity-local/edge-workers/basic/workers/headers/worker.json b/testdata/parity-local/edge-workers/basic/workers/headers/worker.json new file mode 100644 index 000000000..cd6fd9859 --- /dev/null +++ b/testdata/parity-local/edge-workers/basic/workers/headers/worker.json @@ -0,0 +1 @@ +{"name": "headers", "entry": "assembly/index.ts"} diff --git a/testdata/parity-local/edge-workers/two-workers/edge-workers.json b/testdata/parity-local/edge-workers/two-workers/edge-workers.json new file mode 100644 index 000000000..7837a5bd5 --- /dev/null +++ b/testdata/parity-local/edge-workers/two-workers/edge-workers.json @@ -0,0 +1 @@ +{"type":"assemblyscript"} diff --git a/testdata/parity-local/edge-workers/two-workers/workers/a/assembly/index.ts b/testdata/parity-local/edge-workers/two-workers/workers/a/assembly/index.ts new file mode 100644 index 000000000..a3be17d2d --- /dev/null +++ b/testdata/parity-local/edge-workers/two-workers/workers/a/assembly/index.ts @@ -0,0 +1 @@ +// fixture source diff --git a/testdata/parity-local/edge-workers/two-workers/workers/a/worker.json b/testdata/parity-local/edge-workers/two-workers/workers/a/worker.json new file mode 100644 index 000000000..c3e84b151 --- /dev/null +++ b/testdata/parity-local/edge-workers/two-workers/workers/a/worker.json @@ -0,0 +1 @@ +{"name": "a", "entry": "assembly/index.ts"} diff --git a/testdata/parity-local/edge-workers/two-workers/workers/b/assembly/index.ts b/testdata/parity-local/edge-workers/two-workers/workers/b/assembly/index.ts new file mode 100644 index 000000000..a3be17d2d --- /dev/null +++ b/testdata/parity-local/edge-workers/two-workers/workers/b/assembly/index.ts @@ -0,0 +1 @@ +// fixture source diff --git a/testdata/parity-local/edge-workers/two-workers/workers/b/worker.json b/testdata/parity-local/edge-workers/two-workers/workers/b/worker.json new file mode 100644 index 000000000..3a0fbb988 --- /dev/null +++ b/testdata/parity-local/edge-workers/two-workers/workers/b/worker.json @@ -0,0 +1 @@ +{"name": "b", "entry": "assembly/index.ts"} diff --git a/testdata/parity/edge-workers-artifact-symlink.yaml b/testdata/parity/edge-workers-artifact-symlink.yaml new file mode 100644 index 000000000..2e4afdcdf --- /dev/null +++ b/testdata/parity/edge-workers-artifact-symlink.yaml @@ -0,0 +1,8 @@ +name: edge-workers-artifact-symlink +description: edge workers artifact symlink +argv: ["edge-workers", "validate", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-bare-location.yaml b/testdata/parity/edge-workers-bare-location.yaml new file mode 100644 index 000000000..4b22647fb --- /dev/null +++ b/testdata/parity/edge-workers-bare-location.yaml @@ -0,0 +1,8 @@ +name: edge-workers-bare-location +description: edge workers bare location +argv: ["edge-workers", "new", "new-worker", "--location"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-bare-path.yaml b/testdata/parity/edge-workers-bare-path.yaml new file mode 100644 index 000000000..8f28e799a --- /dev/null +++ b/testdata/parity/edge-workers-bare-path.yaml @@ -0,0 +1,8 @@ +name: edge-workers-bare-path +description: edge workers bare path +argv: ["edge-workers", "build", "--path"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-bare-type.yaml b/testdata/parity/edge-workers-bare-type.yaml new file mode 100644 index 000000000..5827a9a8c --- /dev/null +++ b/testdata/parity/edge-workers-bare-type.yaml @@ -0,0 +1,8 @@ +name: edge-workers-bare-type +description: edge workers bare type +argv: ["edge-workers", "init", "--type"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-build-all.yaml b/testdata/parity/edge-workers-build-all.yaml new file mode 100644 index 000000000..cc945f917 --- /dev/null +++ b/testdata/parity/edge-workers-build-all.yaml @@ -0,0 +1,8 @@ +name: edge-workers-build-all +description: edge workers build all +argv: ["edge-workers", "build", "--all"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-build-compiler-error.yaml b/testdata/parity/edge-workers-build-compiler-error.yaml new file mode 100644 index 000000000..b1be19c8b --- /dev/null +++ b/testdata/parity/edge-workers-build-compiler-error.yaml @@ -0,0 +1,8 @@ +name: edge-workers-build-compiler-error +description: edge workers build compiler error +argv: ["edge-workers", "build"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-build-empty.yaml b/testdata/parity/edge-workers-build-empty.yaml new file mode 100644 index 000000000..9bab4f575 --- /dev/null +++ b/testdata/parity/edge-workers-build-empty.yaml @@ -0,0 +1,8 @@ +name: edge-workers-build-empty +description: edge workers build empty +argv: ["edge-workers", "build"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-build-missing-compiler.yaml b/testdata/parity/edge-workers-build-missing-compiler.yaml new file mode 100644 index 000000000..e3ed66e64 --- /dev/null +++ b/testdata/parity/edge-workers-build-missing-compiler.yaml @@ -0,0 +1,8 @@ +name: edge-workers-build-missing-compiler +description: edge workers build missing compiler +argv: ["edge-workers", "build"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-build-name-all.yaml b/testdata/parity/edge-workers-build-name-all.yaml new file mode 100644 index 000000000..2e127612b --- /dev/null +++ b/testdata/parity/edge-workers-build-name-all.yaml @@ -0,0 +1,8 @@ +name: edge-workers-build-name-all +description: edge workers build name all +argv: ["edge-workers", "build", "headers", "--all"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-build-one.yaml b/testdata/parity/edge-workers-build-one.yaml new file mode 100644 index 000000000..23a2d2fcf --- /dev/null +++ b/testdata/parity/edge-workers-build-one.yaml @@ -0,0 +1,8 @@ +name: edge-workers-build-one +description: edge workers build one +argv: ["edge-workers", "build", "headers"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-delete-force.yaml b/testdata/parity/edge-workers-delete-force.yaml new file mode 100644 index 000000000..37384bb4a --- /dev/null +++ b/testdata/parity/edge-workers-delete-force.yaml @@ -0,0 +1,8 @@ +name: edge-workers-delete-force +description: edge workers delete force +argv: ["edge-workers", "delete", "headers", "--force", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-delete-rejected.yaml b/testdata/parity/edge-workers-delete-rejected.yaml new file mode 100644 index 000000000..b52f051ca --- /dev/null +++ b/testdata/parity/edge-workers-delete-rejected.yaml @@ -0,0 +1,8 @@ +name: edge-workers-delete-rejected +description: edge workers delete rejected +argv: ["edge-workers", "delete", "headers", "--force", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-deploy-active-update-enable.yaml b/testdata/parity/edge-workers-deploy-active-update-enable.yaml new file mode 100644 index 000000000..d743018f0 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-active-update-enable.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-active-update-enable +description: edge workers deploy active update enable +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop", "--enable"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-active-update.yaml b/testdata/parity/edge-workers-deploy-active-update.yaml new file mode 100644 index 000000000..719828cfc --- /dev/null +++ b/testdata/parity/edge-workers-deploy-active-update.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-active-update +description: edge workers deploy active update +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-all-preparation-failure.yaml b/testdata/parity/edge-workers-deploy-all-preparation-failure.yaml new file mode 100644 index 000000000..8a95e5071 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-all-preparation-failure.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-all-preparation-failure +description: edge workers deploy all preparation failure +argv: ["edge-workers", "deploy", "--all", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-deploy-create-enable.yaml b/testdata/parity/edge-workers-deploy-create-enable.yaml new file mode 100644 index 000000000..3b92f884b --- /dev/null +++ b/testdata/parity/edge-workers-deploy-create-enable.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-create-enable +description: edge workers deploy create enable +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop", "--enable"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-create.yaml b/testdata/parity/edge-workers-deploy-create.yaml new file mode 100644 index 000000000..fbfd9cbe9 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-create.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-create +description: edge workers deploy create +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-enable-failure.yaml b/testdata/parity/edge-workers-deploy-enable-failure.yaml new file mode 100644 index 000000000..fbfb29153 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-enable-failure.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-enable-failure +description: edge workers deploy enable failure +argv: ["edge-workers", "deploy", "--all", "--skip-build", "--enable", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-deploy-inactive-update-enable.yaml b/testdata/parity/edge-workers-deploy-inactive-update-enable.yaml new file mode 100644 index 000000000..f34e6082f --- /dev/null +++ b/testdata/parity/edge-workers-deploy-inactive-update-enable.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-inactive-update-enable +description: edge workers deploy inactive update enable +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop", "--enable"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-inactive-update.yaml b/testdata/parity/edge-workers-deploy-inactive-update.yaml new file mode 100644 index 000000000..42de9df8b --- /dev/null +++ b/testdata/parity/edge-workers-deploy-inactive-update.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-inactive-update +description: edge workers deploy inactive update +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-location-clear.yaml b/testdata/parity/edge-workers-deploy-location-clear.yaml new file mode 100644 index 000000000..711c13f02 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-location-clear.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-location-clear +description: edge workers deploy location clear +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-location-preserve.yaml b/testdata/parity/edge-workers-deploy-location-preserve.yaml new file mode 100644 index 000000000..9073a1e51 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-location-preserve.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-location-preserve +description: edge workers deploy location preserve +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-location-replace.yaml b/testdata/parity/edge-workers-deploy-location-replace.yaml new file mode 100644 index 000000000..e71ba3ab7 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-location-replace.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-location-replace +description: edge workers deploy location replace +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-name-all.yaml b/testdata/parity/edge-workers-deploy-name-all.yaml new file mode 100644 index 000000000..023fdb603 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-name-all.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-name-all +description: edge workers deploy name all +argv: ["edge-workers", "deploy", "headers", "--all", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-deploy-production-refused.yaml b/testdata/parity/edge-workers-deploy-production-refused.yaml new file mode 100644 index 000000000..f4ee04775 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-production-refused.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-production-refused +description: edge workers deploy production refused +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=production"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-deploy-production-skip.yaml b/testdata/parity/edge-workers-deploy-production-skip.yaml new file mode 100644 index 000000000..9466628f3 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-production-skip.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-production-skip +description: edge workers deploy production skip +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--skip-confirmation", "--app=42", "--env=production"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-skip-source-create.yaml b/testdata/parity/edge-workers-deploy-skip-source-create.yaml new file mode 100644 index 000000000..573f26103 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-skip-source-create.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-skip-source-create +description: edge workers deploy skip source create +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--skip-source", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-skip-source.yaml b/testdata/parity/edge-workers-deploy-skip-source.yaml new file mode 100644 index 000000000..adb22a1ad --- /dev/null +++ b/testdata/parity/edge-workers-deploy-skip-source.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-skip-source +description: edge workers deploy skip source +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--skip-source", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-skip-validate.yaml b/testdata/parity/edge-workers-deploy-skip-validate.yaml new file mode 100644 index 000000000..f9abf6119 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-skip-validate.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-skip-validate +description: edge workers deploy skip validate +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--skip-validate", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-source-empty.yaml b/testdata/parity/edge-workers-deploy-source-empty.yaml new file mode 100644 index 000000000..c69a04643 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-source-empty.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-source-empty +description: edge workers deploy source empty +argv: ["edge-workers", "deploy", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-deploy-upload-failure.yaml b/testdata/parity/edge-workers-deploy-upload-failure.yaml new file mode 100644 index 000000000..53d2ade73 --- /dev/null +++ b/testdata/parity/edge-workers-deploy-upload-failure.yaml @@ -0,0 +1,8 @@ +name: edge-workers-deploy-upload-failure +description: edge workers deploy upload failure +argv: ["edge-workers", "deploy", "--all", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-disable.yaml b/testdata/parity/edge-workers-disable.yaml new file mode 100644 index 000000000..86d216db8 --- /dev/null +++ b/testdata/parity/edge-workers-disable.yaml @@ -0,0 +1,8 @@ +name: edge-workers-disable +description: edge workers disable +argv: ["edge-workers", "disable", "headers", "--app=42", "--env=production"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-double-dash.yaml b/testdata/parity/edge-workers-double-dash.yaml new file mode 100644 index 000000000..6dabc1cfd --- /dev/null +++ b/testdata/parity/edge-workers-double-dash.yaml @@ -0,0 +1,8 @@ +name: edge-workers-double-dash +description: edge workers double dash +argv: ["edge-workers", "new", "--", "--name"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-duplicate-names.yaml b/testdata/parity/edge-workers-duplicate-names.yaml new file mode 100644 index 000000000..a65234efb --- /dev/null +++ b/testdata/parity/edge-workers-duplicate-names.yaml @@ -0,0 +1,8 @@ +name: edge-workers-duplicate-names +description: edge workers duplicate names +argv: ["edge-workers", "build"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-enable-production-refused.yaml b/testdata/parity/edge-workers-enable-production-refused.yaml new file mode 100644 index 000000000..f50ebcffb --- /dev/null +++ b/testdata/parity/edge-workers-enable-production-refused.yaml @@ -0,0 +1,8 @@ +name: edge-workers-enable-production-refused +description: edge workers enable production refused +argv: ["edge-workers", "enable", "headers", "--app=42", "--env=production"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-enable.yaml b/testdata/parity/edge-workers-enable.yaml new file mode 100644 index 000000000..afd7bc169 --- /dev/null +++ b/testdata/parity/edge-workers-enable.yaml @@ -0,0 +1,8 @@ +name: edge-workers-enable +description: edge workers enable +argv: ["edge-workers", "enable", "headers", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-entry-escape.yaml b/testdata/parity/edge-workers-entry-escape.yaml new file mode 100644 index 000000000..b59112b9b --- /dev/null +++ b/testdata/parity/edge-workers-entry-escape.yaml @@ -0,0 +1,8 @@ +name: edge-workers-entry-escape +description: edge workers entry escape +argv: ["edge-workers", "build"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-get-empty-source.yaml b/testdata/parity/edge-workers-get-empty-source.yaml new file mode 100644 index 000000000..92e371c2f --- /dev/null +++ b/testdata/parity/edge-workers-get-empty-source.yaml @@ -0,0 +1,8 @@ +name: edge-workers-get-empty-source +description: edge workers get empty source +argv: ["edge-workers", "get", "headers", "--source", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-get-missing.yaml b/testdata/parity/edge-workers-get-missing.yaml new file mode 100644 index 000000000..d16742df9 --- /dev/null +++ b/testdata/parity/edge-workers-get-missing.yaml @@ -0,0 +1,8 @@ +name: edge-workers-get-missing +description: edge workers get missing +argv: ["edge-workers", "get", "missing", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-get-no-source.yaml b/testdata/parity/edge-workers-get-no-source.yaml new file mode 100644 index 000000000..6d5d6d8be --- /dev/null +++ b/testdata/parity/edge-workers-get-no-source.yaml @@ -0,0 +1,8 @@ +name: edge-workers-get-no-source +description: edge workers get no source +argv: ["edge-workers", "get", "headers", "--source", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-get-source.yaml b/testdata/parity/edge-workers-get-source.yaml new file mode 100644 index 000000000..27282e0df --- /dev/null +++ b/testdata/parity/edge-workers-get-source.yaml @@ -0,0 +1,8 @@ +name: edge-workers-get-source +description: Preserve source formatting and literal escapes while neutralizing terminal controls +argv: ["edge-workers", "get", "headers", "--source", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-get.yaml b/testdata/parity/edge-workers-get.yaml new file mode 100644 index 000000000..2b4f6cea7 --- /dev/null +++ b/testdata/parity/edge-workers-get.yaml @@ -0,0 +1,8 @@ +name: edge-workers-get +description: edge workers get +argv: ["edge-workers", "get", "headers", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-init-invalid-type.yaml b/testdata/parity/edge-workers-init-invalid-type.yaml new file mode 100644 index 000000000..8498146a5 --- /dev/null +++ b/testdata/parity/edge-workers-init-invalid-type.yaml @@ -0,0 +1,8 @@ +name: edge-workers-init-invalid-type +description: edge workers init invalid type +argv: ["edge-workers", "init", "--type=rust"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-init-nonempty.yaml b/testdata/parity/edge-workers-init-nonempty.yaml new file mode 100644 index 000000000..2a8df3980 --- /dev/null +++ b/testdata/parity/edge-workers-init-nonempty.yaml @@ -0,0 +1,8 @@ +name: edge-workers-init-nonempty +description: edge workers init nonempty +argv: ["edge-workers", "init", "."] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-init.yaml b/testdata/parity/edge-workers-init.yaml new file mode 100644 index 000000000..f18287db0 --- /dev/null +++ b/testdata/parity/edge-workers-init.yaml @@ -0,0 +1,8 @@ +name: edge-workers-init +description: edge workers init +argv: ["edge-workers", "init"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-json-control-name.yaml b/testdata/parity/edge-workers-json-control-name.yaml new file mode 100644 index 000000000..6ea109734 --- /dev/null +++ b/testdata/parity/edge-workers-json-control-name.yaml @@ -0,0 +1,8 @@ +name: edge-workers-json-control-name +description: edge workers json control name +argv: ["edge-workers", "list", "--format=json", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-list-csv.yaml b/testdata/parity/edge-workers-list-csv.yaml new file mode 100644 index 000000000..13cc73cf7 --- /dev/null +++ b/testdata/parity/edge-workers-list-csv.yaml @@ -0,0 +1,8 @@ +name: edge-workers-list-csv +description: edge workers list csv +argv: ["edge-workers", "list", "--format=csv", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-list-empty-json.yaml b/testdata/parity/edge-workers-list-empty-json.yaml new file mode 100644 index 000000000..c0bd625f4 --- /dev/null +++ b/testdata/parity/edge-workers-list-empty-json.yaml @@ -0,0 +1,8 @@ +name: edge-workers-list-empty-json +description: edge workers list empty json +argv: ["edge-workers", "list", "--format=json", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-list-empty.yaml b/testdata/parity/edge-workers-list-empty.yaml new file mode 100644 index 000000000..32c6c1445 --- /dev/null +++ b/testdata/parity/edge-workers-list-empty.yaml @@ -0,0 +1,8 @@ +name: edge-workers-list-empty +description: edge workers list empty +argv: ["edge-workers", "list", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-list-json.yaml b/testdata/parity/edge-workers-list-json.yaml new file mode 100644 index 000000000..3f320f9bf --- /dev/null +++ b/testdata/parity/edge-workers-list-json.yaml @@ -0,0 +1,8 @@ +name: edge-workers-list-json +description: edge workers list json +argv: ["edge-workers", "list", "--format=json", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-list-table.yaml b/testdata/parity/edge-workers-list-table.yaml new file mode 100644 index 000000000..20a1eb2ad --- /dev/null +++ b/testdata/parity/edge-workers-list-table.yaml @@ -0,0 +1,8 @@ +name: edge-workers-list-table +description: edge workers list table +argv: ["edge-workers", "list", "--format=table", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-mutation-graphql-error.yaml b/testdata/parity/edge-workers-mutation-graphql-error.yaml new file mode 100644 index 000000000..b4b7b5985 --- /dev/null +++ b/testdata/parity/edge-workers-mutation-graphql-error.yaml @@ -0,0 +1,8 @@ +name: edge-workers-mutation-graphql-error +description: edge workers mutation graphql error +argv: ["edge-workers", "enable", "headers", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-mutation-null.yaml b/testdata/parity/edge-workers-mutation-null.yaml new file mode 100644 index 000000000..4b64c64ef --- /dev/null +++ b/testdata/parity/edge-workers-mutation-null.yaml @@ -0,0 +1,8 @@ +name: edge-workers-mutation-null +description: edge workers mutation null +argv: ["edge-workers", "enable", "headers", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-new-invalid-location.yaml b/testdata/parity/edge-workers-new-invalid-location.yaml new file mode 100644 index 000000000..ceebd3bfb --- /dev/null +++ b/testdata/parity/edge-workers-new-invalid-location.yaml @@ -0,0 +1,8 @@ +name: edge-workers-new-invalid-location +description: edge workers new invalid location +argv: ["edge-workers", "new", "new-worker", "--location=bad"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-new-invalid-name.yaml b/testdata/parity/edge-workers-new-invalid-name.yaml new file mode 100644 index 000000000..9df09c498 --- /dev/null +++ b/testdata/parity/edge-workers-new-invalid-name.yaml @@ -0,0 +1,8 @@ +name: edge-workers-new-invalid-name +description: edge workers new invalid name +argv: ["edge-workers", "new", "../bad"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-new-location.yaml b/testdata/parity/edge-workers-new-location.yaml new file mode 100644 index 000000000..62a32321a --- /dev/null +++ b/testdata/parity/edge-workers-new-location.yaml @@ -0,0 +1,8 @@ +name: edge-workers-new-location +description: edge workers new location +argv: ["edge-workers", "new", "new-worker", "--location=starts_with:/api/"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-new.yaml b/testdata/parity/edge-workers-new.yaml new file mode 100644 index 000000000..d53b69acd --- /dev/null +++ b/testdata/parity/edge-workers-new.yaml @@ -0,0 +1,8 @@ +name: edge-workers-new +description: edge workers new +argv: ["edge-workers", "new", "new-worker"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-read-null.yaml b/testdata/parity/edge-workers-read-null.yaml new file mode 100644 index 000000000..6b09c00f3 --- /dev/null +++ b/testdata/parity/edge-workers-read-null.yaml @@ -0,0 +1,8 @@ +name: edge-workers-read-null +description: edge workers read null +argv: ["edge-workers", "list", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-repeated-path.yaml b/testdata/parity/edge-workers-repeated-path.yaml new file mode 100644 index 000000000..8c2c82ef3 --- /dev/null +++ b/testdata/parity/edge-workers-repeated-path.yaml @@ -0,0 +1,8 @@ +name: edge-workers-repeated-path +description: edge workers repeated path +argv: ["edge-workers", "build", "--path=.", "--path=."] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-repeated-type.yaml b/testdata/parity/edge-workers-repeated-type.yaml new file mode 100644 index 000000000..50ba966ff --- /dev/null +++ b/testdata/parity/edge-workers-repeated-type.yaml @@ -0,0 +1,8 @@ +name: edge-workers-repeated-type +description: edge workers repeated type +argv: ["edge-workers", "init", "--type=assemblyscript", "--type=assemblyscript"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-short-equals.yaml b/testdata/parity/edge-workers-short-equals.yaml new file mode 100644 index 000000000..ba2476542 --- /dev/null +++ b/testdata/parity/edge-workers-short-equals.yaml @@ -0,0 +1,8 @@ +name: edge-workers-short-equals +description: edge workers short equals +argv: ["edge-workers", "new", "new-worker", "-p=PROJECT_DIR", "-l=equals:/x"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-table-control-name.yaml b/testdata/parity/edge-workers-table-control-name.yaml new file mode 100644 index 000000000..0e537c2de --- /dev/null +++ b/testdata/parity/edge-workers-table-control-name.yaml @@ -0,0 +1,8 @@ +name: edge-workers-table-control-name +description: edge workers table control name +argv: ["edge-workers", "list", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-unicode-order.yaml b/testdata/parity/edge-workers-unicode-order.yaml new file mode 100644 index 000000000..15191b847 --- /dev/null +++ b/testdata/parity/edge-workers-unicode-order.yaml @@ -0,0 +1,8 @@ +name: edge-workers-unicode-order +description: edge workers unicode order +argv: ["edge-workers", "build", "--all"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-validate-all.yaml b/testdata/parity/edge-workers-validate-all.yaml new file mode 100644 index 000000000..891b2b1e5 --- /dev/null +++ b/testdata/parity/edge-workers-validate-all.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate-all +description: edge workers validate all +argv: ["edge-workers", "validate", "--all", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/edge-workers-validate-invalid.yaml b/testdata/parity/edge-workers-validate-invalid.yaml new file mode 100644 index 000000000..2d6055545 --- /dev/null +++ b/testdata/parity/edge-workers-validate-invalid.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate-invalid +description: edge workers validate invalid +argv: ["edge-workers", "validate", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-validate-name-all.yaml b/testdata/parity/edge-workers-validate-name-all.yaml new file mode 100644 index 000000000..9b8ae801d --- /dev/null +++ b/testdata/parity/edge-workers-validate-name-all.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate-name-all +description: edge workers validate name all +argv: ["edge-workers", "validate", "headers", "--all", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-validate-no-artifact.yaml b/testdata/parity/edge-workers-validate-no-artifact.yaml new file mode 100644 index 000000000..3467aeb0b --- /dev/null +++ b/testdata/parity/edge-workers-validate-no-artifact.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate-no-artifact +description: edge workers validate no artifact +argv: ["edge-workers", "validate", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-validate-no-name.yaml b/testdata/parity/edge-workers-validate-no-name.yaml new file mode 100644 index 000000000..906fa243d --- /dev/null +++ b/testdata/parity/edge-workers-validate-no-name.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate-no-name +description: edge workers validate no name +argv: ["edge-workers", "validate", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-validate-null.yaml b/testdata/parity/edge-workers-validate-null.yaml new file mode 100644 index 000000000..1a8193c9d --- /dev/null +++ b/testdata/parity/edge-workers-validate-null.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate-null +description: edge workers validate null +argv: ["edge-workers", "validate", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 1 diff --git a/testdata/parity/edge-workers-validate.yaml b/testdata/parity/edge-workers-validate.yaml new file mode 100644 index 000000000..039d504eb --- /dev/null +++ b/testdata/parity/edge-workers-validate.yaml @@ -0,0 +1,8 @@ +name: edge-workers-validate +description: edge workers validate +argv: ["edge-workers", "validate", "headers", "--skip-build", "--app=42", "--env=develop"] +env: + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +expect: + exit_code: 0