From b52927ed0dbc10b5496499d6ee499b7a26d8b062 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Fri, 28 Aug 2026 14:56:29 -0500 Subject: [PATCH 1/3] feat(go): add Edge Workers commands #### Purpose and Context Bring the Go runtime up to date with the Node Edge Workers command set. #### Key Changes Add all ten commands, project scaffolding and compilation, GraphQL operations, and deployment planning. Preserve activation safeguards, source and location semantics, terminal escaping, and error handling. #### Impact and Considerations New workers remain inactive unless explicitly enabled. Local builds still use the project's Node-based AssemblyScript compiler. SEA changes are excluded. #### Testing and Validation Add unit, real runtime differential, terminal prompt, and compiler smoke coverage. Go tests and vet, Node tests and type checks, and normal and hostile fixture parity pass. Local integration parity remains unverified because a required local API service is stopped. --- __tests__/lib/edge-workers/project.js | 13 + cmd/vip-next/commands/edge_workers.go | 197 ++ cmd/vip-next/commands/edge_workers_flags.go | 56 + cmd/vip-next/commands/edge_workers_local.go | 127 ++ .../commands/edge_workers_mutation.go | 130 ++ cmd/vip-next/commands/edge_workers_read.go | 103 ++ cmd/vip-next/commands/edge_workers_test.go | 266 +++ cmd/vip-next/edge_workers_test.go | 82 + cmd/vip-next/flags_node_parity_test.go | 15 +- cmd/vip-next/root.go | 1 + docs/EDGE-WORKERS.md | 5 + go.mod | 2 +- internal/edgeworkers/api.go | 173 ++ internal/edgeworkers/api_test.go | 256 +++ internal/edgeworkers/build.go | 188 ++ internal/edgeworkers/build_test.go | 175 ++ internal/edgeworkers/confirmation.go | 72 + internal/edgeworkers/deployment.go | 156 ++ internal/edgeworkers/deployment_test.go | 165 ++ internal/edgeworkers/output.go | 119 ++ internal/edgeworkers/output_test.go | 78 + internal/edgeworkers/project.go | 264 +++ internal/edgeworkers/project_test.go | 167 ++ internal/edgeworkers/scaffold.go | 88 + internal/edgeworkers/templates/README.md | 25 + .../edgeworkers/templates/edge-workers.json | 4 + internal/edgeworkers/templates/gitignore | 2 + internal/edgeworkers/templates/package.json | 16 + internal/edgeworkers/templates/tsconfig.json | 6 + internal/edgeworkers/templates/worker.ts | 19 + internal/edgeworkers/types.go | 37 + internal/edgeworkers/validation.go | 177 ++ internal/gql/edgeworkerinput/input.go | 58 + internal/gql/edgeworkerinput/input_test.go | 52 + internal/gql/generated.go | 1596 ++++++++++++++++- internal/gql/genqlient.yaml | 6 + internal/gql/operations/edge_workers.graphql | 53 + internal/gql/schema.gql | 30 +- internal/output/json.go | 19 +- internal/output/output_test.go | 18 + .../parity/edge_workers_build_smoke_test.go | 69 + internal/parity/edge_workers_cases_test.go | 77 + .../parity/edge_workers_differential_test.go | 450 +++++ internal/parity/edge_workers_prompt_test.go | 151 ++ internal/parity/runner.go | 2 + internal/parity/runner_test.go | 24 + internal/parity/surface_differential_test.go | 3 + .../edge-workers/basic/edge-workers.json | 1 + .../basic/workers/headers/assembly/index.ts | 1 + .../basic/workers/headers/worker.json | 1 + .../two-workers/edge-workers.json | 1 + .../two-workers/workers/a/assembly/index.ts | 1 + .../two-workers/workers/a/worker.json | 1 + .../two-workers/workers/b/assembly/index.ts | 1 + .../two-workers/workers/b/worker.json | 1 + .../parity/edge-workers-artifact-symlink.yaml | 8 + .../parity/edge-workers-bare-location.yaml | 8 + testdata/parity/edge-workers-bare-path.yaml | 8 + testdata/parity/edge-workers-bare-type.yaml | 8 + testdata/parity/edge-workers-build-all.yaml | 8 + .../edge-workers-build-compiler-error.yaml | 8 + testdata/parity/edge-workers-build-empty.yaml | 8 + .../edge-workers-build-missing-compiler.yaml | 8 + .../parity/edge-workers-build-name-all.yaml | 8 + testdata/parity/edge-workers-build-one.yaml | 8 + .../parity/edge-workers-delete-force.yaml | 8 + .../parity/edge-workers-delete-rejected.yaml | 8 + ...e-workers-deploy-active-update-enable.yaml | 8 + .../edge-workers-deploy-active-update.yaml | 8 + ...orkers-deploy-all-preparation-failure.yaml | 8 + .../edge-workers-deploy-create-enable.yaml | 8 + .../parity/edge-workers-deploy-create.yaml | 8 + .../edge-workers-deploy-enable-failure.yaml | 8 + ...workers-deploy-inactive-update-enable.yaml | 8 + .../edge-workers-deploy-inactive-update.yaml | 8 + .../edge-workers-deploy-location-clear.yaml | 8 + ...edge-workers-deploy-location-preserve.yaml | 8 + .../edge-workers-deploy-location-replace.yaml | 8 + .../parity/edge-workers-deploy-name-all.yaml | 8 + ...dge-workers-deploy-production-refused.yaml | 8 + .../edge-workers-deploy-production-skip.yaml | 8 + ...dge-workers-deploy-skip-source-create.yaml | 8 + .../edge-workers-deploy-skip-source.yaml | 8 + .../edge-workers-deploy-skip-validate.yaml | 8 + .../edge-workers-deploy-source-empty.yaml | 8 + .../edge-workers-deploy-upload-failure.yaml | 8 + testdata/parity/edge-workers-disable.yaml | 8 + testdata/parity/edge-workers-double-dash.yaml | 8 + .../parity/edge-workers-duplicate-names.yaml | 8 + ...dge-workers-enable-production-refused.yaml | 8 + testdata/parity/edge-workers-enable.yaml | 8 + .../parity/edge-workers-entry-escape.yaml | 8 + .../parity/edge-workers-get-empty-source.yaml | 8 + testdata/parity/edge-workers-get-missing.yaml | 8 + .../parity/edge-workers-get-no-source.yaml | 8 + testdata/parity/edge-workers-get-source.yaml | 8 + testdata/parity/edge-workers-get.yaml | 8 + .../edge-workers-init-invalid-type.yaml | 8 + .../parity/edge-workers-init-nonempty.yaml | 8 + testdata/parity/edge-workers-init.yaml | 8 + .../edge-workers-json-control-name.yaml | 8 + testdata/parity/edge-workers-list-csv.yaml | 8 + .../parity/edge-workers-list-empty-json.yaml | 8 + testdata/parity/edge-workers-list-empty.yaml | 8 + testdata/parity/edge-workers-list-json.yaml | 8 + testdata/parity/edge-workers-list-table.yaml | 8 + .../edge-workers-mutation-graphql-error.yaml | 8 + .../parity/edge-workers-mutation-null.yaml | 8 + .../edge-workers-new-invalid-location.yaml | 8 + .../parity/edge-workers-new-invalid-name.yaml | 8 + .../parity/edge-workers-new-location.yaml | 8 + testdata/parity/edge-workers-new.yaml | 8 + testdata/parity/edge-workers-read-null.yaml | 8 + .../parity/edge-workers-repeated-path.yaml | 8 + .../parity/edge-workers-repeated-type.yaml | 8 + .../parity/edge-workers-short-equals.yaml | 8 + .../edge-workers-table-control-name.yaml | 8 + .../parity/edge-workers-unicode-order.yaml | 8 + .../parity/edge-workers-validate-all.yaml | 8 + .../parity/edge-workers-validate-invalid.yaml | 8 + .../edge-workers-validate-name-all.yaml | 8 + .../edge-workers-validate-no-artifact.yaml | 8 + .../parity/edge-workers-validate-no-name.yaml | 8 + .../parity/edge-workers-validate-null.yaml | 8 + testdata/parity/edge-workers-validate.yaml | 8 + 125 files changed, 6299 insertions(+), 61 deletions(-) create mode 100644 cmd/vip-next/commands/edge_workers.go create mode 100644 cmd/vip-next/commands/edge_workers_flags.go create mode 100644 cmd/vip-next/commands/edge_workers_local.go create mode 100644 cmd/vip-next/commands/edge_workers_mutation.go create mode 100644 cmd/vip-next/commands/edge_workers_read.go create mode 100644 cmd/vip-next/commands/edge_workers_test.go create mode 100644 cmd/vip-next/edge_workers_test.go create mode 100644 internal/edgeworkers/api.go create mode 100644 internal/edgeworkers/api_test.go create mode 100644 internal/edgeworkers/build.go create mode 100644 internal/edgeworkers/build_test.go create mode 100644 internal/edgeworkers/confirmation.go create mode 100644 internal/edgeworkers/deployment.go create mode 100644 internal/edgeworkers/deployment_test.go create mode 100644 internal/edgeworkers/output.go create mode 100644 internal/edgeworkers/output_test.go create mode 100644 internal/edgeworkers/project.go create mode 100644 internal/edgeworkers/project_test.go create mode 100644 internal/edgeworkers/scaffold.go create mode 100644 internal/edgeworkers/templates/README.md create mode 100644 internal/edgeworkers/templates/edge-workers.json create mode 100644 internal/edgeworkers/templates/gitignore create mode 100644 internal/edgeworkers/templates/package.json create mode 100644 internal/edgeworkers/templates/tsconfig.json create mode 100644 internal/edgeworkers/templates/worker.ts create mode 100644 internal/edgeworkers/types.go create mode 100644 internal/edgeworkers/validation.go create mode 100644 internal/gql/edgeworkerinput/input.go create mode 100644 internal/gql/edgeworkerinput/input_test.go create mode 100644 internal/gql/operations/edge_workers.graphql create mode 100644 internal/parity/edge_workers_build_smoke_test.go create mode 100644 internal/parity/edge_workers_cases_test.go create mode 100644 internal/parity/edge_workers_differential_test.go create mode 100644 internal/parity/edge_workers_prompt_test.go create mode 100644 testdata/parity-local/edge-workers/basic/edge-workers.json create mode 100644 testdata/parity-local/edge-workers/basic/workers/headers/assembly/index.ts create mode 100644 testdata/parity-local/edge-workers/basic/workers/headers/worker.json create mode 100644 testdata/parity-local/edge-workers/two-workers/edge-workers.json create mode 100644 testdata/parity-local/edge-workers/two-workers/workers/a/assembly/index.ts create mode 100644 testdata/parity-local/edge-workers/two-workers/workers/a/worker.json create mode 100644 testdata/parity-local/edge-workers/two-workers/workers/b/assembly/index.ts create mode 100644 testdata/parity-local/edge-workers/two-workers/workers/b/worker.json create mode 100644 testdata/parity/edge-workers-artifact-symlink.yaml create mode 100644 testdata/parity/edge-workers-bare-location.yaml create mode 100644 testdata/parity/edge-workers-bare-path.yaml create mode 100644 testdata/parity/edge-workers-bare-type.yaml create mode 100644 testdata/parity/edge-workers-build-all.yaml create mode 100644 testdata/parity/edge-workers-build-compiler-error.yaml create mode 100644 testdata/parity/edge-workers-build-empty.yaml create mode 100644 testdata/parity/edge-workers-build-missing-compiler.yaml create mode 100644 testdata/parity/edge-workers-build-name-all.yaml create mode 100644 testdata/parity/edge-workers-build-one.yaml create mode 100644 testdata/parity/edge-workers-delete-force.yaml create mode 100644 testdata/parity/edge-workers-delete-rejected.yaml create mode 100644 testdata/parity/edge-workers-deploy-active-update-enable.yaml create mode 100644 testdata/parity/edge-workers-deploy-active-update.yaml create mode 100644 testdata/parity/edge-workers-deploy-all-preparation-failure.yaml create mode 100644 testdata/parity/edge-workers-deploy-create-enable.yaml create mode 100644 testdata/parity/edge-workers-deploy-create.yaml create mode 100644 testdata/parity/edge-workers-deploy-enable-failure.yaml create mode 100644 testdata/parity/edge-workers-deploy-inactive-update-enable.yaml create mode 100644 testdata/parity/edge-workers-deploy-inactive-update.yaml create mode 100644 testdata/parity/edge-workers-deploy-location-clear.yaml create mode 100644 testdata/parity/edge-workers-deploy-location-preserve.yaml create mode 100644 testdata/parity/edge-workers-deploy-location-replace.yaml create mode 100644 testdata/parity/edge-workers-deploy-name-all.yaml create mode 100644 testdata/parity/edge-workers-deploy-production-refused.yaml create mode 100644 testdata/parity/edge-workers-deploy-production-skip.yaml create mode 100644 testdata/parity/edge-workers-deploy-skip-source-create.yaml create mode 100644 testdata/parity/edge-workers-deploy-skip-source.yaml create mode 100644 testdata/parity/edge-workers-deploy-skip-validate.yaml create mode 100644 testdata/parity/edge-workers-deploy-source-empty.yaml create mode 100644 testdata/parity/edge-workers-deploy-upload-failure.yaml create mode 100644 testdata/parity/edge-workers-disable.yaml create mode 100644 testdata/parity/edge-workers-double-dash.yaml create mode 100644 testdata/parity/edge-workers-duplicate-names.yaml create mode 100644 testdata/parity/edge-workers-enable-production-refused.yaml create mode 100644 testdata/parity/edge-workers-enable.yaml create mode 100644 testdata/parity/edge-workers-entry-escape.yaml create mode 100644 testdata/parity/edge-workers-get-empty-source.yaml create mode 100644 testdata/parity/edge-workers-get-missing.yaml create mode 100644 testdata/parity/edge-workers-get-no-source.yaml create mode 100644 testdata/parity/edge-workers-get-source.yaml create mode 100644 testdata/parity/edge-workers-get.yaml create mode 100644 testdata/parity/edge-workers-init-invalid-type.yaml create mode 100644 testdata/parity/edge-workers-init-nonempty.yaml create mode 100644 testdata/parity/edge-workers-init.yaml create mode 100644 testdata/parity/edge-workers-json-control-name.yaml create mode 100644 testdata/parity/edge-workers-list-csv.yaml create mode 100644 testdata/parity/edge-workers-list-empty-json.yaml create mode 100644 testdata/parity/edge-workers-list-empty.yaml create mode 100644 testdata/parity/edge-workers-list-json.yaml create mode 100644 testdata/parity/edge-workers-list-table.yaml create mode 100644 testdata/parity/edge-workers-mutation-graphql-error.yaml create mode 100644 testdata/parity/edge-workers-mutation-null.yaml create mode 100644 testdata/parity/edge-workers-new-invalid-location.yaml create mode 100644 testdata/parity/edge-workers-new-invalid-name.yaml create mode 100644 testdata/parity/edge-workers-new-location.yaml create mode 100644 testdata/parity/edge-workers-new.yaml create mode 100644 testdata/parity/edge-workers-read-null.yaml create mode 100644 testdata/parity/edge-workers-repeated-path.yaml create mode 100644 testdata/parity/edge-workers-repeated-type.yaml create mode 100644 testdata/parity/edge-workers-short-equals.yaml create mode 100644 testdata/parity/edge-workers-table-control-name.yaml create mode 100644 testdata/parity/edge-workers-unicode-order.yaml create mode 100644 testdata/parity/edge-workers-validate-all.yaml create mode 100644 testdata/parity/edge-workers-validate-invalid.yaml create mode 100644 testdata/parity/edge-workers-validate-name-all.yaml create mode 100644 testdata/parity/edge-workers-validate-no-artifact.yaml create mode 100644 testdata/parity/edge-workers-validate-no-name.yaml create mode 100644 testdata/parity/edge-workers-validate-null.yaml create mode 100644 testdata/parity/edge-workers-validate.yaml 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..719b9a184 --- /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 edge-workers init\n vip 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 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 edge-workers new add-security-headers\n vip edge-workers new my-worker --path ./infra/edge\n vip 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 @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 edge-workers build\n vip 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..7a367c728 --- /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 @example-app.develop edge-workers deploy my-worker\n vip @example-app.develop edge-workers deploy --all\n vip @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 @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..11e3f6563 --- /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 @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 @example-app.production edge-workers get my-worker\n vip @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 @example-app.develop edge-workers validate my-worker\n vip @example-app.develop edge-workers validate --all\n vip @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..93544850e --- /dev/null +++ b/cmd/vip-next/commands/edge_workers_test.go @@ -0,0 +1,266 @@ +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", 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) + } + 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) + } + 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..33fcb6cf4 --- /dev/null +++ b/cmd/vip-next/edge_workers_test.go @@ -0,0 +1,82 @@ +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()) + } +} 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/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 90d1c3d9e..5fdec8988 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, 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..92a9daac3 --- /dev/null +++ b/internal/edgeworkers/output.go @@ -0,0 +1,119 @@ +package edgeworkers + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Automattic/vip/internal/output" +) + +func EscapeTerminalText(s string) string { + var out strings.Builder + for _, r := range s { + 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 = EscapeTerminalText(*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..417e9006c --- /dev/null +++ b/internal/edgeworkers/output_test.go @@ -0,0 +1,78 @@ +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 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..a4d5faf8d --- /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: "inactive", 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..1f3782c50 --- /dev/null +++ b/internal/parity/edge_workers_differential_test.go @@ -0,0 +1,450 @@ +//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", "null-mutation", "false-delete", "graphql-error", "control-name": + w := edgeRemoteWorker("headers", state == "active") + 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. Only the known runtime banner and prefix spacing differ; + // command messages, request payloads, and every other byte still compare. + 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") + results[side] = result + 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/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..0c3e50d57 --- /dev/null +++ b/testdata/parity/edge-workers-get-source.yaml @@ -0,0 +1,8 @@ +name: edge-workers-get-source +description: edge workers get 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.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 From 600a8680a9e808c1eb2d9889df10b8fe64733ec3 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Fri, 28 Aug 2026 15:14:03 -0500 Subject: [PATCH 2/3] fix(edge-workers): preserve source formatting #### Purpose and Context Both runtimes print stored worker source through the single-line terminal sanitizer. It turns ordinary newlines and tabs into literal Unicode escape sequences, making get --source output unreadable. #### Key Changes Add source-specific escaping in Node and Go and use it only when rendering stored source. Preserve LF and horizontal tabs, normalize CRLF to LF, and continue escaping standalone CR, ANSI escapes, and other C0, DEL, and C1 controls. Keep literal backslash sequences intact. #### Impact and Considerations Names, metadata, errors, stored source, and deployment behavior remain unchanged. The fix is display-only; workers do not need redeployment. #### Testing and Validation Reproduce the failure before the fix in both unit suites and through the real CLIs. Add equivalent formatting and terminal-safety cases and an independent expected-output assertion to source parity so matching runtime bugs cannot pass. Verify Go tests/vet/build, 255 Node tests, type checking/lint/build, and normal and hostile fixture parity. --- __tests__/bin/vip-edge-workers-get.js | 25 +++++++++++++++++++ docs/EDGE-WORKERS.md | 3 +++ internal/edgeworkers/output.go | 16 ++++++++++-- internal/edgeworkers/output_test.go | 20 +++++++++++++++ internal/parity/edge_workers_cases_test.go | 2 +- .../parity/edge_workers_differential_test.go | 13 +++++++++- src/bin/vip-edge-workers-get.js | 4 +-- src/lib/edge-workers/output.ts | 10 ++++++++ testdata/parity/edge-workers-get-source.yaml | 2 +- 9 files changed, 88 insertions(+), 7 deletions(-) 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/docs/EDGE-WORKERS.md b/docs/EDGE-WORKERS.md index 5fdec8988..d6be1c1a3 100644 --- a/docs/EDGE-WORKERS.md +++ b/docs/EDGE-WORKERS.md @@ -164,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/internal/edgeworkers/output.go b/internal/edgeworkers/output.go index 92a9daac3..e8b49273e 100644 --- a/internal/edgeworkers/output.go +++ b/internal/edgeworkers/output.go @@ -9,9 +9,21 @@ import ( ) 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 r <= 31 || r >= 127 && r <= 159 { + 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) @@ -59,7 +71,7 @@ func DetailText(w Worker, source bool) string { if source { value := "(no source stored)" if w.Source != nil { - value = EscapeTerminalText(*w.Source) + value = EscapeTerminalSource(*w.Source) } text += "\n\nSource:\n" + value } diff --git a/internal/edgeworkers/output_test.go b/internal/edgeworkers/output_test.go index 417e9006c..3e8b68ebf 100644 --- a/internal/edgeworkers/output_test.go +++ b/internal/edgeworkers/output_test.go @@ -24,6 +24,26 @@ func TestListJSONPreservesControlValues(t *testing.T) { } } +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) diff --git a/internal/parity/edge_workers_cases_test.go b/internal/parity/edge_workers_cases_test.go index a4d5faf8d..4a03aad2b 100644 --- a/internal/parity/edge_workers_cases_test.go +++ b/internal/parity/edge_workers_cases_test.go @@ -23,7 +23,7 @@ var edgeWorkerScenarios = map[string]edgeWorkerCase{ "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: "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}, diff --git a/internal/parity/edge_workers_differential_test.go b/internal/parity/edge_workers_differential_test.go index 1f3782c50..227ff8fc6 100644 --- a/internal/parity/edge_workers_differential_test.go +++ b/internal/parity/edge_workers_differential_test.go @@ -86,8 +86,11 @@ func edgeRemoteWorker(name string, active bool) map[string]any { 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", "null-mutation", "false-delete", "graphql-error", "control-name": + 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"] = "" } @@ -393,6 +396,14 @@ func TestEdgeWorkersDifferentialParity(t *testing.T) { result.Stdout = strings.ReplaceAll(result.Stdout, dir, "PROJECT_DIR") result.Stderr = strings.ReplaceAll(result.Stderr, dir, "PROJECT_DIR") 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" { 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/edge-workers-get-source.yaml b/testdata/parity/edge-workers-get-source.yaml index 0c3e50d57..27282e0df 100644 --- a/testdata/parity/edge-workers-get-source.yaml +++ b/testdata/parity/edge-workers-get-source.yaml @@ -1,5 +1,5 @@ name: edge-workers-get-source -description: 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" From 85c5da36fb0d54f76c426a9650486911602e9952 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Fri, 28 Aug 2026 16:01:41 -0500 Subject: [PATCH 3/3] fix(go): use vip-next in Edge Workers guidance #### Purpose and Context The Go Edge Workers examples and scaffold next steps point at the Node vip executable. Copying them can switch runtimes unintentionally. #### Key Changes Use vip-next in all ten Go help examples and the init/new next-step messages. Leave Node guidance and shared project templates unchanged. #### Impact and Considerations Document the approved executable-name difference in the cutover notes. Parity first checks each runtime's actual executable name, then normalizes only the known init/new guidance prefixes. Arguments, source contents, filesystem effects, and API requests still compare. #### Testing and Validation Add rendered-help coverage for all ten Go commands and next-step assertions in both runtimes. Verify expected failures before the fix, then run Go tests, vet/build, Node init/new tests and lint, and real Node-vs-Go fixture parity in normal and hostile environments. --- __tests__/bin/vip-edge-workers-init.js | 1 + cmd/vip-next/commands/edge_workers_local.go | 10 +++---- .../commands/edge_workers_mutation.go | 4 +-- cmd/vip-next/commands/edge_workers_read.go | 6 ++--- cmd/vip-next/commands/edge_workers_test.go | 8 +++++- cmd/vip-next/edge_workers_test.go | 26 +++++++++++++++++++ docs/CUTOVER-BREAKING-CHANGES.md | 1 + .../parity/edge_workers_differential_test.go | 26 +++++++++++++++++-- 8 files changed, 69 insertions(+), 13 deletions(-) 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/cmd/vip-next/commands/edge_workers_local.go b/cmd/vip-next/commands/edge_workers_local.go index 719b9a184..f5667621d 100644 --- a/cmd/vip-next/commands/edge_workers_local.go +++ b/cmd/vip-next/commands/edge_workers_local.go @@ -11,7 +11,7 @@ import ( ) func newEdgeWorkersInitCmd(deps edgeWorkersDeps) *cobra.Command { - c := &cobra.Command{Use: "init [path]", Short: "Scaffold a new edge-workers project.", Example: " vip edge-workers init\n vip edge-workers init ./infra/edge --type=assemblyscript"} + 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) @@ -40,13 +40,13 @@ func newEdgeWorkersInitCmd(deps edgeWorkersDeps) *cobra.Command { 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 edge-workers new my-worker\n", kindText, edgeworkers.EscapeTerminalText(dir), edgeworkers.EscapeTerminalText(target)) + _, 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 edge-workers new add-security-headers\n vip edge-workers new my-worker --path ./infra/edge\n vip edge-workers new api-auth --location starts_with:/api/"} + 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 { @@ -86,7 +86,7 @@ func newEdgeWorkersNewCmd(deps edgeWorkersDeps) *cobra.Command { } 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 @my-site.develop edge-workers deploy %s\n", edgeworkers.EscapeTerminalText(name)) + _, 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 { @@ -96,7 +96,7 @@ func newEdgeWorkersNewCmd(deps edgeWorkersDeps) *cobra.Command { }) } func newEdgeWorkersBuildCmd(deps edgeWorkersDeps) *cobra.Command { - c := &cobra.Command{Use: "build [name]", Short: "Compile worker(s) to WebAssembly locally.", Example: " vip edge-workers build\n vip edge-workers build my-worker"} + 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 { diff --git a/cmd/vip-next/commands/edge_workers_mutation.go b/cmd/vip-next/commands/edge_workers_mutation.go index 7a367c728..6e59b480c 100644 --- a/cmd/vip-next/commands/edge_workers_mutation.go +++ b/cmd/vip-next/commands/edge_workers_mutation.go @@ -10,7 +10,7 @@ import ( ) func newEdgeWorkersDeployCmd(deps edgeWorkersDeps) *cobra.Command { - c := &cobra.Command{Use: "deploy [name]", Short: "Compile and deploy a worker to an environment.", Example: " vip @example-app.develop edge-workers deploy my-worker\n vip @example-app.develop edge-workers deploy --all\n vip @example-app.develop edge-workers deploy my-worker --skip-build"} + 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.") @@ -74,7 +74,7 @@ func newEdgeWorkersDeployCmd(deps edgeWorkersDeps) *cobra.Command { } 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 @example-app.production edge-workers " + action + " my-worker"} + 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.") } diff --git a/cmd/vip-next/commands/edge_workers_read.go b/cmd/vip-next/commands/edge_workers_read.go index 11e3f6563..efc3642df 100644 --- a/cmd/vip-next/commands/edge_workers_read.go +++ b/cmd/vip-next/commands/edge_workers_read.go @@ -10,7 +10,7 @@ import ( ) func newEdgeWorkersListCmd(deps edgeWorkersDeps) *cobra.Command { - c := &cobra.Command{Use: "list", Short: "List the edge workers deployed to an environment.", Example: " vip @example-app.production edge-workers list"} + 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) @@ -30,7 +30,7 @@ func newEdgeWorkersListCmd(deps edgeWorkersDeps) *cobra.Command { }) } func newEdgeWorkersGetCmd(deps edgeWorkersDeps) *cobra.Command { - c := &cobra.Command{Use: "get ", Short: "Retrieve details for a single deployed edge worker.", Example: " vip @example-app.production edge-workers get my-worker\n vip @example-app.production edge-workers get my-worker --source"} + 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) @@ -52,7 +52,7 @@ func newEdgeWorkersGetCmd(deps edgeWorkersDeps) *cobra.Command { }) } func newEdgeWorkersValidateCmd(deps edgeWorkersDeps) *cobra.Command { - c := &cobra.Command{Use: "validate [name]", Short: "Validate worker(s) against an environment without deploying.", Example: " vip @example-app.develop edge-workers validate my-worker\n vip @example-app.develop edge-workers validate --all\n vip @example-app.develop edge-workers validate my-worker --skip-build"} + 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.") diff --git a/cmd/vip-next/commands/edge_workers_test.go b/cmd/vip-next/commands/edge_workers_test.go index 93544850e..c3eb098f7 100644 --- a/cmd/vip-next/commands/edge_workers_test.go +++ b/cmd/vip-next/commands/edge_workers_test.go @@ -120,7 +120,7 @@ func edgeCommandConfig(t *testing.T) { } func runEdgeCommand(t *testing.T, deps edgeWorkersDeps, args ...string) (string, error) { t.Helper() - root := &cobra.Command{Use: "vip", SilenceUsage: true, SilenceErrors: true} + 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 @@ -157,10 +157,16 @@ func TestEdgeWorkersLocalCommands(t *testing.T) { 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) diff --git a/cmd/vip-next/edge_workers_test.go b/cmd/vip-next/edge_workers_test.go index 33fcb6cf4..cf7be2e41 100644 --- a/cmd/vip-next/edge_workers_test.go +++ b/cmd/vip-next/edge_workers_test.go @@ -80,3 +80,29 @@ func TestEdgeWorkersHelpDoesNotExposeBareFlagMarker(t *testing.T) { 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/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/internal/parity/edge_workers_differential_test.go b/internal/parity/edge_workers_differential_test.go index 227ff8fc6..314df8e28 100644 --- a/internal/parity/edge_workers_differential_test.go +++ b/internal/parity/edge_workers_differential_test.go @@ -374,8 +374,8 @@ func TestEdgeWorkersDifferentialParity(t *testing.T) { t.Fatal("Edge Workers must not broaden accepted drift") } // Preserve the existing cutover contract, docs/CUTOVER-BREAKING-CHANGES.md - // section 1.10. Only the known runtime banner and prefix spacing differ; - // command messages, request payloads, and every other byte still compare. + // 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) @@ -395,6 +395,28 @@ func TestEdgeWorkersDifferentialParity(t *testing.T) { } 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.