diff --git a/cmd/build.go b/cmd/build.go index 1c16cdce9b..ce6aa9e343 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -202,7 +202,26 @@ func runBuild(cmd *cobra.Command, _ []string, newClient ClientFactory) (err erro } // Stamp is a performance optimization: treat the function as being built // (cached) unless the fs changes. - return f.Stamp() + if err = f.Stamp(); err != nil { + return + } + if isJSONEnabled(cmd) { + image := f.Build.Image + if image == "" { + image = f.Image + } + err = writeJSONSuccess(cmd.OutOrStdout(), buildJSONResult{ + Name: f.Name, + Image: image, + }) + } + return +} + +// buildJSONResult is the data payload emitted on success when --json is set. +type buildJSONResult struct { + Name string `json:"name"` + Image string `json:"image,omitempty"` } // warnRegistryInsecureChange checks if the registry has changed but diff --git a/cmd/completion.go b/cmd/completion.go index 34311c3cbb..aa2e16fc77 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -2,6 +2,7 @@ package cmd import ( "errors" + "fmt" "os" "github.com/spf13/cobra" @@ -27,6 +28,9 @@ source <(func completion bash) ValidArgs: []string{"bash", "zsh", "fish"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) (err error) { + if isJSONEnabled(cmd) { + return fmt.Errorf("--json is not supported for 'completion': it outputs raw shell completion scripts") + } if len(args) < 1 { return errors.New("missing argument") } diff --git a/cmd/config_git.go b/cmd/config_git.go index c000188edb..af9eb90abb 100644 --- a/cmd/config_git.go +++ b/cmd/config_git.go @@ -48,9 +48,11 @@ the current directory or from the directory specified with --path. return cmd } -func runConfigGitCmd(_ *cobra.Command, _ ClientFactory) (err error) { - fmt.Printf("--------------------------- Function Git config ---------------------------\n") - fmt.Printf("Not implemented yet.\n") - +func runConfigGitCmd(cmd *cobra.Command, _ ClientFactory) (err error) { + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), nil) + } + fmt.Fprintln(cmd.ErrOrStderr(), "--------------------------- Function Git config ---------------------------") + fmt.Fprintln(cmd.ErrOrStderr(), "Not implemented yet.") return nil } diff --git a/cmd/create.go b/cmd/create.go index 0ef5cbfe9f..2cdf31bab4 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -136,7 +136,7 @@ func runCreate(cmd *cobra.Command, args []string, newClient ClientFactory) (err } // Create - _, err = client.Init(fn.Function{ + f, err := client.Init(fn.Function{ Name: cfg.Name, Root: cfg.Path, Runtime: cfg.Runtime, @@ -145,11 +145,27 @@ func runCreate(cmd *cobra.Command, args []string, newClient ClientFactory) (err if err != nil { return err } + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), createJSONResult{ + Name: f.Name, + Path: f.Root, + Runtime: f.Runtime, + Template: cfg.Template, + }) + } // Confirm fmt.Fprintf(cmd.OutOrStderr(), "Created %v function in %v\n", cfg.Runtime, cfg.Path) return nil } +// createJSONResult is the data payload emitted on success when --json is set. +type createJSONResult struct { + Name string `json:"name"` + Path string `json:"path"` + Runtime string `json:"runtime"` + Template string `json:"template,omitempty"` +} + type createConfig struct { Path string // Absolute path to function source Runtime string // Language Runtime diff --git a/cmd/delete.go b/cmd/delete.go index 103a6270c4..8557541312 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -81,15 +81,37 @@ func runDelete(cmd *cobra.Command, args []string, newClient ClientFactory) (err client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) defer done() + var deletedName, deletedNamespace string if cfg.Name != "" { // Delete by name if provided - return client.Remove(cmd.Context(), cfg.Name, cfg.Namespace, fn.Function{}, cfg.All) + deletedName = cfg.Name + deletedNamespace = cfg.Namespace + if err = client.Remove(cmd.Context(), cfg.Name, cfg.Namespace, fn.Function{}, cfg.All); err != nil { + return + } } else { // Otherwise; delete the function at path (cwd by default) f, err := fn.NewFunction(cfg.Path) if err != nil { return err } - return client.Remove(cmd.Context(), "", "", f, cfg.All) + deletedName = f.Name + deletedNamespace = f.Deploy.Namespace + if err = client.Remove(cmd.Context(), "", "", f, cfg.All); err != nil { + return err + } + } + if isJSONEnabled(cmd) { + err = writeJSONSuccess(cmd.OutOrStdout(), deleteJSONResult{ + Name: deletedName, + Namespace: deletedNamespace, + }) } + return +} + +// deleteJSONResult is the data payload emitted on success when --json is set. +type deleteJSONResult struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` } type deleteConfig struct { diff --git a/cmd/deploy.go b/cmd/deploy.go index faf4f3109b..cc0e32b198 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -300,12 +300,13 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { if changingNamespace(f) && k8s.IsOpenShift() && k8s.IsOpenShiftInternalRegistry(f.Registry) { f.Registry = "image-registry.openshift-image-registry.svc:5000/" + f.Namespace if cfg.Verbose { - fmt.Fprintf(cmd.OutOrStdout(), "Info: Overriding openshift registry to %s\n", f.Registry) + fmt.Fprintf(cmd.ErrOrStderr(), "Info: Overriding openshift registry to %s\n", f.Registry) } } - // Informative non-error messages regarding the final deployment request - printDeployMessages(cmd.OutOrStdout(), f) + // Informative non-error messages: always go to stderr so that --json + // output on stdout is not contaminated with human-readable status text. + printDeployMessages(cmd.ErrOrStderr(), f) // Get options based on the value of the config such as concrete impls // of builders and pushers based on the value of the --builder flag @@ -317,6 +318,7 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { defer done() // Deploy + var deployedURL string if cfg.Remote { // Write func.yaml before the pipeline uploads sources to the PVC, // so that the on-cluster deploy step sees the latest config @@ -331,7 +333,10 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { if url, f, err = client.RunPipeline(cmd.Context(), f); err != nil { return wrapDeploymentError(err) } - fmt.Fprintf(cmd.OutOrStdout(), "Function Deployed at %v\n", url) + deployedURL = url + if !isJSONEnabled(cmd) { + fmt.Fprintf(cmd.OutOrStdout(), "Function Deployed at %v\n", url) + } } else { var buildOptions []fn.BuildOption if buildOptions, err = cfg.buildOptions(); err != nil { @@ -387,6 +392,13 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { if f, err = client.Deploy(cmd.Context(), f, fn.WithDeploySkipBuildCheck(cfg.Build == "false")); err != nil { return wrapDeploymentError(err) } + // Capture the deployed URL for --json output. The URL is not + // returned directly by Deploy; a lightweight Describe call fetches it. + if isJSONEnabled(cmd) { + if inst, descErr := client.Describe(cmd.Context(), "", "", f); descErr == nil && len(inst.Routes) > 0 { + deployedURL = inst.Routes[0] + } + } } // Write @@ -398,7 +410,26 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { // Updates the build stamp because building must have been accomplished // during this process, and a future call to deploy without any appreciable // changes to the filesystem should not rebuild again unless `--build` - return f.Stamp() + if err = f.Stamp(); err != nil { + return + } + if isJSONEnabled(cmd) { + err = writeJSONSuccess(cmd.OutOrStdout(), deployJSONResult{ + Name: f.Name, + Namespace: f.Deploy.Namespace, + URL: deployedURL, + Image: f.Deploy.Image, + }) + } + return +} + +// deployJSONResult is the data payload emitted on success when --json is set. +type deployJSONResult struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + URL string `json:"url,omitempty"` + Image string `json:"image,omitempty"` } // build determines if the function should be built based on given flag diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 540cb4e15f..dd370b34cd 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -1165,17 +1165,18 @@ func TestDeploy_NamespaceRedeployWarning(t *testing.T) { fn.WithRegistry(TestRegistry), )) cmd.SetArgs([]string{}) - stdout := strings.Builder{} - cmd.SetOut(&stdout) + stderr := strings.Builder{} + cmd.SetErr(&stderr) if err := cmd.Execute(); err != nil { t.Fatal(err) } expected := "Warning: namespace chosen is 'funcns', but currently active namespace is 'mynamespace'. Continuing with deployment to 'funcns'." - // Ensure output contained warning if changing namespace - if !strings.Contains(stdout.String(), expected) { - t.Log("STDOUT:\n" + stdout.String()) + // Ensure warning appears on stderr (deploy messages always go to stderr + // so they don't contaminate stdout when --json is active). + if !strings.Contains(stderr.String(), expected) { + t.Log("STDERR:\n" + stderr.String()) t.Fatalf("Expected warning not found:\n%v", expected) } diff --git a/cmd/describe.go b/cmd/describe.go index 5a6f4a8d49..8edf6fbe71 100644 --- a/cmd/describe.go +++ b/cmd/describe.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "encoding/json" "fmt" "io" @@ -89,6 +90,14 @@ func runDescribe(cmd *cobra.Command, args []string, newClient ClientFactory) (er } } + if isJSONEnabled(cmd) { + var buf bytes.Buffer + if err = info(details).JSON(&buf); err != nil { + return + } + var raw json.RawMessage = buf.Bytes() + return writeJSONSuccess(cmd.OutOrStdout(), raw) + } write(os.Stdout, info(details), cfg.Output) return } diff --git a/cmd/environment.go b/cmd/environment.go index 31aee04f31..bf7f393172 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -154,6 +154,10 @@ func runEnvironment(cmd *cobra.Command, newClient ClientFactory, v *Version) (er environment.Instance = instance } + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), environment) + } + var s []byte switch cfg.Format { case "json": diff --git a/cmd/invoke.go b/cmd/invoke.go index 9ff96d37fa..f52256f38f 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -212,6 +212,13 @@ func runInvoke(cmd *cobra.Command, _ []string, newClient ClientFactory) (err err return err } + // When --json: emit structured envelope only – no human text on stdout. + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), invokeJSONResult{ + Response: body, + }) + } + // When Verbose // - Print an explicit "Received response" indicator // - Print metadata (headers for HTTP requests, CloudEvents already include @@ -221,17 +228,17 @@ func runInvoke(cmd *cobra.Command, _ []string, newClient ClientFactory) (err err // stdout could be confusing on a first-time run, viewing a proper echo. // user feedback suggests this actually be placed behind the --verbose // setting: - fmt.Println("Function invoked. Response:") + fmt.Fprintln(cmd.ErrOrStderr(), "Function invoked. Response:") if len(metadata) > 0 { - fmt.Println(" Metadata:") + fmt.Fprintln(cmd.ErrOrStderr(), " Metadata:") } for k, vv := range metadata { values := strings.Join(vv, ";") - fmt.Fprintf(cmd.OutOrStdout(), " %v: %v\n", k, values) + fmt.Fprintf(cmd.ErrOrStderr(), " %v: %v\n", k, values) } if len(metadata) > 0 { - fmt.Println(" Content:") + fmt.Fprintln(cmd.ErrOrStderr(), " Content:") } } @@ -241,6 +248,11 @@ func runInvoke(cmd *cobra.Command, _ []string, newClient ClientFactory) (err err return } +// invokeJSONResult is the data payload emitted on success when --json is set. +type invokeJSONResult struct { + Response string `json:"response"` +} + type invokeConfig struct { Path string Target string diff --git a/cmd/json.go b/cmd/json.go new file mode 100644 index 0000000000..9d11c2597a --- /dev/null +++ b/cmd/json.go @@ -0,0 +1,475 @@ +package cmd + +import ( + "encoding/json" + "errors" + "io" + + "github.com/spf13/cobra" + "knative.dev/func/pkg/docker" + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/oci" +) + +const jsonAPIVersion = "v1" + +// isJSONEnabled reports whether --json was explicitly set for this execution. +// Using cmd.Flag("json").Changed (rather than viper.GetBool("json")) avoids +// stale viper state polluting test runs. +func isJSONEnabled(cmd *cobra.Command) bool { + f := cmd.Flag("json") + return f != nil && f.Changed +} + +// JSONResponse is the top-level envelope for all --json output. +type JSONResponse struct { + APIVersion string `json:"apiVersion"` + Status string `json:"status"` // "ok" or "error" + Data any `json:"data,omitempty"` + Error *JSONError `json:"error,omitempty"` +} + +// JSONError carries structured failure information for machine consumers. +type JSONError struct { + Category string `json:"category"` + Code string `json:"code"` + Retryable bool `json:"retryable"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Context map[string]string `json:"context,omitempty"` +} + +// WriteJSONSuccess writes a success envelope containing data to w. +// Exported for use in tests and pkg/app. +func WriteJSONSuccess(w io.Writer, data any) error { + return json.NewEncoder(w).Encode(JSONResponse{ + APIVersion: jsonAPIVersion, + Status: "ok", + Data: data, + }) +} + +// writeJSONSuccess is the package-internal alias. +func writeJSONSuccess(w io.Writer, data any) error { + return WriteJSONSuccess(w, data) +} + +// WriteJSONError classifies err and writes an error envelope to w. +// Exported so that pkg/app can call it from the top-level error sink. +func WriteJSONError(w io.Writer, err error) error { + return json.NewEncoder(w).Encode(JSONResponse{ + APIVersion: jsonAPIVersion, + Status: "error", + Error: errorToJSONError(err), + }) +} + +// errorToJSONError maps a Go error to a structured JSONError by inspecting +// the known typed errors in the cmd and pkg/functions layers. +func errorToJSONError(err error) *JSONError { + if err == nil { + return nil + } + + // --- CLUSTER errors --- + + var clusterNotAccessible *ErrClusterNotAccessible + if errors.As(err, &clusterNotAccessible) { + return &JSONError{ + Category: "CLUSTER_ERROR", + Code: "CLUSTER_NOT_ACCESSIBLE", + Retryable: true, + Message: clusterNotAccessible.Err.Error(), + Hint: "Verify your cluster is running: kubectl cluster-info", + } + } + + var listClusterConn *ErrListClusterConnection + if errors.As(err, &listClusterConn) { + return &JSONError{ + Category: "CLUSTER_ERROR", + Code: "CLUSTER_NOT_ACCESSIBLE", + Retryable: true, + Message: listClusterConn.Err.Error(), + Hint: "Verify your cluster is running: kubectl cluster-info", + } + } + + var invalidKubeconfig *ErrInvalidKubeconfig + if errors.As(err, &invalidKubeconfig) { + return &JSONError{ + Category: "CLUSTER_ERROR", + Code: "INVALID_KUBECONFIG", + Retryable: false, + Message: invalidKubeconfig.Err.Error(), + Hint: "Check your KUBECONFIG environment variable or ~/.kube/config", + } + } + + // --- AUTH / REGISTRY errors --- + + var registryRequiredCLI *ErrRegistryRequired + if errors.As(err, ®istryRequiredCLI) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "REGISTRY_REQUIRED", + Retryable: false, + Message: registryRequiredCLI.Err.Error(), + Hint: "Provide --registry or set FUNC_REGISTRY", + Context: map[string]string{"command": registryRequiredCLI.Cmd}, + } + } + + if errors.Is(err, fn.ErrRegistryRequired) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "REGISTRY_REQUIRED", + Retryable: false, + Message: err.Error(), + Hint: "Provide --registry or set FUNC_REGISTRY", + } + } + + // --- VALIDATION errors --- + + var conflictImageRegistry *ErrConflictImageRegistry + if errors.As(err, &conflictImageRegistry) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "CONFLICTING_IMAGE_REGISTRY", + Retryable: false, + Message: conflictImageRegistry.Err.Error(), + Hint: "Use either --image or --registry, not both", + Context: map[string]string{"command": conflictImageRegistry.Cmd}, + } + } + + var invalidNamespace *ErrInvalidNamespace + if errors.As(err, &invalidNamespace) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "INVALID_NAMESPACE", + Retryable: false, + Message: invalidNamespace.Err.Error(), + Hint: "Namespace must be lowercase alphanumeric and hyphens only, max 63 chars", + Context: map[string]string{"command": invalidNamespace.Cmd}, + } + } + + var invalidDomain *ErrInvalidDomain + if errors.As(err, &invalidDomain) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "INVALID_DOMAIN", + Retryable: false, + Message: invalidDomain.Err.Error(), + Hint: "Domain must be a valid DNS subdomain", + Context: map[string]string{"command": invalidDomain.Cmd}, + } + } + + var platformNotSupported *ErrPlatformNotSupported + if errors.As(err, &platformNotSupported) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "PLATFORM_NOT_SUPPORTED", + Retryable: false, + Message: platformNotSupported.Err.Error(), + Hint: "--platform is only supported with s2i and pack builders", + Context: map[string]string{"command": platformNotSupported.Cmd}, + } + } + + var notInitializedCLI *ErrNotInitialized + if errors.As(err, ¬InitializedCLI) { + ctx := map[string]string{} + if notInitializedCLI.Cmd != "" { + ctx["command"] = notInitializedCLI.Cmd + } + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "NOT_INITIALIZED", + Retryable: false, + Message: notInitializedCLI.Err.Error(), + Hint: "Run 'func create' to initialize a function first", + Context: ctx, + } + } + + var notInitializedCore *fn.ErrNotInitialized + if errors.As(err, ¬InitializedCore) { + ctx := map[string]string{} + if notInitializedCore.Path != "" { + ctx["path"] = notInitializedCore.Path + } + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "NOT_INITIALIZED", + Retryable: false, + Message: notInitializedCore.Error(), + Hint: "Run 'func create' to initialize a function first", + Context: ctx, + } + } + + var deleteNameRequired *ErrDeleteNameRequired + if errors.As(err, &deleteNameRequired) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "NAME_REQUIRED", + Retryable: false, + Message: deleteNameRequired.Err.Error(), + Hint: "Provide a function name or use --path", + } + } + + var deleteNamespaceRequired *ErrDeleteNamespaceRequired + if errors.As(err, &deleteNamespaceRequired) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "NAMESPACE_REQUIRED", + Retryable: false, + Message: deleteNamespaceRequired.Err.Error(), + Hint: "Provide --namespace or use --path to a function with a recorded namespace", + } + } + + if errors.Is(err, fn.ErrNameRequired) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "NAME_REQUIRED", + Retryable: false, + Message: err.Error(), + } + } + + if errors.Is(err, fn.ErrNamespaceRequired) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "NAMESPACE_REQUIRED", + Retryable: false, + Message: err.Error(), + } + } + + // --- PORT / RUN errors --- + + var portPermissionDenied *ErrPortPermissionDenied + if errors.As(err, &portPermissionDenied) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "PORT_PERMISSION_DENIED", + Retryable: false, + Message: portPermissionDenied.Error(), + Hint: "Use a non-privileged port (>1024) or run with elevated permissions", + Context: map[string]string{"port": portPermissionDenied.Port}, + } + } + + var portUnavailable *ErrPortUnavailable + if errors.As(err, &portUnavailable) { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "PORT_UNAVAILABLE", + Retryable: true, + Message: portUnavailable.Err.Error(), + Hint: "Try a different port with --address", + Context: map[string]string{"port": portUnavailable.Port}, + } + } + + // --- TEMPLATE / REPOSITORY errors --- + + if errors.Is(err, fn.ErrTemplateNotFound) { + return &JSONError{ + Category: "TEMPLATE_ERROR", + Code: "TEMPLATE_NOT_FOUND", + Retryable: false, + Message: err.Error(), + Hint: "Run 'func repository list' to see available repositories and templates", + } + } + + if errors.Is(err, fn.ErrTemplatesNotFound) { + return &JSONError{ + Category: "TEMPLATE_ERROR", + Code: "TEMPLATES_NOT_FOUND", + Retryable: false, + Message: err.Error(), + Hint: "The repository may be missing a 'templates' directory", + } + } + + if errors.Is(err, fn.ErrTemplateMissingRepository) { + return &JSONError{ + Category: "TEMPLATE_ERROR", + Code: "TEMPLATE_MISSING_REPOSITORY", + Retryable: false, + Message: err.Error(), + Hint: "Specify the repository prefix, e.g. 'myrepo/http'", + } + } + + if errors.Is(err, fn.ErrRepositoryNotFound) { + return &JSONError{ + Category: "TEMPLATE_ERROR", + Code: "REPOSITORY_NOT_FOUND", + Retryable: false, + Message: err.Error(), + Hint: "Add the repository with 'func repository add'", + } + } + + if errors.Is(err, fn.ErrRepositoriesNotDefined) { + return &JSONError{ + Category: "TEMPLATE_ERROR", + Code: "REPOSITORIES_NOT_DEFINED", + Retryable: false, + Message: err.Error(), + Hint: "Set FUNC_REPOSITORIES_PATH or add a repository with 'func repository add'", + } + } + + // --- RUNTIME errors --- + + if errors.Is(err, fn.ErrRuntimeNotFound) { + return &JSONError{ + Category: "RUNTIME_ERROR", + Code: "RUNTIME_NOT_FOUND", + Retryable: false, + Message: err.Error(), + Hint: "Run 'func languages' to see supported runtimes", + } + } + + if errors.Is(err, fn.ErrRuntimeRequired) { + return &JSONError{ + Category: "RUNTIME_ERROR", + Code: "RUNTIME_REQUIRED", + Retryable: false, + Message: err.Error(), + Hint: "Provide --language or set the runtime in func.yaml", + } + } + + var runtimeNotRecognized fn.ErrRuntimeNotRecognized + if errors.As(err, &runtimeNotRecognized) { + return &JSONError{ + Category: "RUNTIME_ERROR", + Code: "RUNTIME_NOT_RECOGNIZED", + Retryable: false, + Message: runtimeNotRecognized.Error(), + Hint: "Run 'func languages' to see supported runtimes", + Context: map[string]string{"runtime": runtimeNotRecognized.Runtime}, + } + } + + var runnerNotImplemented fn.ErrRunnerNotImplemented + if errors.As(err, &runnerNotImplemented) { + return &JSONError{ + Category: "RUNTIME_ERROR", + Code: "RUNNER_NOT_IMPLEMENTED", + Retryable: false, + Message: runnerNotImplemented.Error(), + Hint: "Use 'func deploy' to run containerized functions", + Context: map[string]string{"runtime": runnerNotImplemented.Runtime}, + } + } + + var runTimeout fn.ErrRunTimeout + if errors.As(err, &runTimeout) { + return &JSONError{ + Category: "RUNTIME_ERROR", + Code: "RUN_TIMEOUT", + Retryable: true, + Message: runTimeout.Error(), + Hint: "The function did not become ready in time; check container logs", + } + } + + // --- FUNCTION state errors --- + + if errors.Is(err, fn.ErrFunctionNotFound) { + return &JSONError{ + Category: "NOT_FOUND", + Code: "FUNCTION_NOT_FOUND", + Retryable: false, + Message: err.Error(), + } + } + + if errors.Is(err, fn.ErrNotRunning) { + return &JSONError{ + Category: "NOT_FOUND", + Code: "FUNCTION_NOT_RUNNING", + Retryable: false, + Message: err.Error(), + Hint: "Start the function with 'func run' or 'func deploy'", + } + } + + // --- PORT / RUN errors (pkg/functions layer) --- + + var portUnavailableCore *fn.ErrPortUnavailableError + if errors.As(err, &portUnavailableCore) { + if portUnavailableCore.IsPermissionDenied() { + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "PORT_PERMISSION_DENIED", + Retryable: false, + Message: portUnavailableCore.Error(), + Hint: "Use a non-privileged port (>1024) or run with elevated permissions", + Context: map[string]string{"port": portUnavailableCore.Port}, + } + } + return &JSONError{ + Category: "VALIDATION_ERROR", + Code: "PORT_UNAVAILABLE", + Retryable: true, + Message: portUnavailableCore.Error(), + Hint: "Try a different port with --address", + Context: map[string]string{"port": portUnavailableCore.Port}, + } + } + + // --- BUILD errors --- + + if errors.Is(err, docker.ErrNoDocker) { + return &JSONError{ + Category: "BUILD_ERROR", + Code: "DOCKER_NOT_AVAILABLE", + Retryable: true, + Message: err.Error(), + Hint: "Ensure Docker or Podman daemon is running", + } + } + + var buildErr oci.BuildErr + if errors.As(err, &buildErr) { + return &JSONError{ + Category: "BUILD_ERROR", + Code: "BUILD_FAILED", + Retryable: true, + Message: buildErr.Err.Error(), + } + } + + if errors.Is(err, fn.ErrNotBuilt) { + return &JSONError{ + Category: "BUILD_ERROR", + Code: "NOT_BUILT", + Retryable: false, + Message: err.Error(), + Hint: "Run 'func build' before deploying", + } + } + + // --- fallback --- + + return &JSONError{ + Category: "UNKNOWN_ERROR", + Code: "UNKNOWN", + Retryable: false, + Message: err.Error(), + } +} diff --git a/cmd/json_test.go b/cmd/json_test.go new file mode 100644 index 0000000000..b27f9a3ac8 --- /dev/null +++ b/cmd/json_test.go @@ -0,0 +1,250 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "knative.dev/func/cmd" + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/oci" +) + +// -- envelope shape --------------------------------------------------------- + +func TestWriteJSONSuccess_EnvelopeShape(t *testing.T) { + var buf bytes.Buffer + type payload struct { + Name string `json:"name"` + } + if err := cmd.WriteJSONSuccess(&buf, payload{Name: "myfunc"}); err != nil { + t.Fatalf("WriteJSONSuccess returned error: %v", err) + } + + var resp cmd.JSONResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("success envelope is not valid JSON: %v", err) + } + if resp.APIVersion != "v1" { + t.Errorf("expected apiVersion 'v1', got %q", resp.APIVersion) + } + if resp.Status != "ok" { + t.Errorf("expected status 'ok', got %q", resp.Status) + } + if resp.Error != nil { + t.Errorf("expected nil error in success envelope, got %+v", resp.Error) + } + if resp.Data == nil { + t.Error("expected non-nil data in success envelope") + } +} + +func TestWriteJSONError_EnvelopeShape(t *testing.T) { + var buf bytes.Buffer + if err := cmd.WriteJSONError(&buf, errors.New("something broke")); err != nil { + t.Fatalf("WriteJSONError returned error: %v", err) + } + + var resp cmd.JSONResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("error envelope is not valid JSON: %v", err) + } + if resp.APIVersion != "v1" { + t.Errorf("expected apiVersion 'v1', got %q", resp.APIVersion) + } + if resp.Status != "error" { + t.Errorf("expected status 'error', got %q", resp.Status) + } + if resp.Error == nil { + t.Fatal("expected non-nil error field in error envelope") + } + if resp.Data != nil { + t.Errorf("expected nil data in error envelope, got %v", resp.Data) + } +} + +func TestAPIVersionAlwaysPresent(t *testing.T) { + tests := []struct { + name string + fn func(*bytes.Buffer) error + }{ + {"success", func(b *bytes.Buffer) error { return cmd.WriteJSONSuccess(b, "data") }}, + {"error", func(b *bytes.Buffer) error { return cmd.WriteJSONError(b, errors.New("err")) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + if err := tc.fn(&buf); err != nil { + t.Fatal(err) + } + var resp cmd.JSONResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("not valid JSON: %v", err) + } + if resp.APIVersion != "v1" { + t.Errorf("apiVersion: want 'v1' got %q", resp.APIVersion) + } + }) + } +} + +// -- errorToJSONError category/code mapping --------------------------------- + +func TestErrorToJSONError_ClusterNotAccessible(t *testing.T) { + inner := errors.New("dial tcp: connection refused") + err := cmd.NewErrClusterNotAccessible(inner) + assertJSONError(t, err, "CLUSTER_ERROR", "CLUSTER_NOT_ACCESSIBLE", true) +} + +func TestErrorToJSONError_InvalidKubeconfig(t *testing.T) { + inner := errors.New("kubeconfig not found") + err := cmd.NewErrInvalidKubeconfig(inner) + assertJSONError(t, err, "CLUSTER_ERROR", "INVALID_KUBECONFIG", false) +} + +func TestErrorToJSONError_RegistryRequiredCLI(t *testing.T) { + inner := fn.ErrRegistryRequired + err := cmd.NewErrRegistryRequired(inner, "build") + assertJSONError(t, err, "VALIDATION_ERROR", "REGISTRY_REQUIRED", false) +} + +func TestErrorToJSONError_RegistryRequiredCore(t *testing.T) { + assertJSONError(t, fn.ErrRegistryRequired, "VALIDATION_ERROR", "REGISTRY_REQUIRED", false) +} + +func TestErrorToJSONError_ConflictImageRegistry(t *testing.T) { + inner := fn.ErrConflictingImageAndRegistry + err := cmd.NewErrConflictImageRegistry(inner, "build") + assertJSONError(t, err, "VALIDATION_ERROR", "CONFLICTING_IMAGE_REGISTRY", false) +} + +func TestErrorToJSONError_InvalidNamespace(t *testing.T) { + inner := fn.ErrInvalidNamespace + err := cmd.NewErrInvalidNamespace(inner, "deploy") + assertJSONError(t, err, "VALIDATION_ERROR", "INVALID_NAMESPACE", false) +} + +func TestErrorToJSONError_InvalidDomain(t *testing.T) { + inner := fn.ErrInvalidDomain + err := cmd.NewErrInvalidDomain(inner, "deploy") + assertJSONError(t, err, "VALIDATION_ERROR", "INVALID_DOMAIN", false) +} + +func TestErrorToJSONError_PlatformNotSupported(t *testing.T) { + inner := fn.ErrPlatformNotSupported + err := cmd.NewErrPlatformNotSupported(inner, "build") + assertJSONError(t, err, "VALIDATION_ERROR", "PLATFORM_NOT_SUPPORTED", false) +} + +func TestErrorToJSONError_NotInitializedCLI(t *testing.T) { + inner := fn.NewErrNotInitialized("/path") + err := cmd.NewErrNotInitialized(inner, "deploy") + assertJSONError(t, err, "VALIDATION_ERROR", "NOT_INITIALIZED", false) +} + +func TestErrorToJSONError_NotInitializedCore(t *testing.T) { + err := fn.NewErrNotInitialized("/some/path") + assertJSONError(t, err, "VALIDATION_ERROR", "NOT_INITIALIZED", false) +} + +func TestErrorToJSONError_DeleteNameRequired(t *testing.T) { + err := cmd.NewErrDeleteNameRequired(fn.ErrNameRequired) + assertJSONError(t, err, "VALIDATION_ERROR", "NAME_REQUIRED", false) +} + +func TestErrorToJSONError_DeleteNamespaceRequired(t *testing.T) { + err := cmd.NewErrDeleteNamespaceRequired(fn.ErrNamespaceRequired) + assertJSONError(t, err, "VALIDATION_ERROR", "NAMESPACE_REQUIRED", false) +} + +func TestErrorToJSONError_NotBuilt(t *testing.T) { + assertJSONError(t, fn.ErrNotBuilt, "BUILD_ERROR", "NOT_BUILT", false) +} + +func TestErrorToJSONError_BuildFailed(t *testing.T) { + err := oci.BuildErr{Err: errors.New("build failed")} + assertJSONError(t, err, "BUILD_ERROR", "BUILD_FAILED", true) +} + +func TestErrorToJSONError_Unknown(t *testing.T) { + assertJSONError(t, errors.New("totally unknown error"), "UNKNOWN_ERROR", "UNKNOWN", false) +} + +func TestErrorToJSONError_MessagePreserved(t *testing.T) { + msg := "my specific error message" + var buf bytes.Buffer + if err := cmd.WriteJSONError(&buf, errors.New(msg)); err != nil { + t.Fatal(err) + } + var resp cmd.JSONResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Error.Message != msg { + t.Errorf("expected message %q, got %q", msg, resp.Error.Message) + } +} + +// -- round-trip validity ---------------------------------------------------- + +func TestWriteJSONSuccess_RoundTrip(t *testing.T) { + type payload struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } + p := payload{Name: "myfunc", Namespace: "prod"} + + var buf bytes.Buffer + if err := cmd.WriteJSONSuccess(&buf, p); err != nil { + t.Fatal(err) + } + + var resp struct { + APIVersion string `json:"apiVersion"` + Status string `json:"status"` + Data payload `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("round-trip decode failed: %v", err) + } + if resp.Data.Name != p.Name { + t.Errorf("round-trip name: want %q got %q", p.Name, resp.Data.Name) + } + if resp.Data.Namespace != p.Namespace { + t.Errorf("round-trip namespace: want %q got %q", p.Namespace, resp.Data.Namespace) + } + if resp.APIVersion != "v1" { + t.Errorf("round-trip apiVersion: want 'v1' got %q", resp.APIVersion) + } + if resp.Status != "ok" { + t.Errorf("round-trip status: want 'ok' got %q", resp.Status) + } +} + +// -- helpers ---------------------------------------------------------------- + +// assertJSONError writes the error as a JSON envelope and checks category/code/retryable. +func assertJSONError(t *testing.T, err error, wantCategory, wantCode string, wantRetryable bool) { + t.Helper() + var buf bytes.Buffer + if werr := cmd.WriteJSONError(&buf, err); werr != nil { + t.Fatalf("WriteJSONError returned error: %v", werr) + } + var resp cmd.JSONResponse + if derr := json.Unmarshal(buf.Bytes(), &resp); derr != nil { + t.Fatalf("result is not valid JSON: %v", derr) + } + if resp.Error == nil { + t.Fatal("expected non-nil error field") + } + if resp.Error.Category != wantCategory { + t.Errorf("category: want %q got %q", wantCategory, resp.Error.Category) + } + if resp.Error.Code != wantCode { + t.Errorf("code: want %q got %q", wantCode, resp.Error.Code) + } + if resp.Error.Retryable != wantRetryable { + t.Errorf("retryable: want %v got %v", wantRetryable, resp.Error.Retryable) + } +} diff --git a/cmd/languages.go b/cmd/languages.go index d0ad7eb187..004956a941 100644 --- a/cmd/languages.go +++ b/cmd/languages.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "github.com/ory/viper" @@ -86,16 +85,10 @@ func runLanguages(cmd *cobra.Command, newClient ClientFactory) (err error) { } if cfg.JSON { - var s []byte - s, err = json.MarshalIndent(runtimes, "", " ") - if err != nil { - return - } - fmt.Fprintln(cmd.OutOrStdout(), string(s)) - } else { - for _, runtime := range runtimes { - fmt.Fprintln(cmd.OutOrStdout(), runtime) - } + return writeJSONSuccess(cmd.OutOrStdout(), runtimes) + } + for _, runtime := range runtimes { + fmt.Fprintln(cmd.OutOrStdout(), runtime) } return } diff --git a/cmd/languages_test.go b/cmd/languages_test.go index 4874b6b2ad..fd8da45590 100644 --- a/cmd/languages_test.go +++ b/cmd/languages_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "testing" . "knative.dev/func/pkg/testing" @@ -33,7 +34,7 @@ typescript` } // TestLanguages_JSON ensures that listing languages in --json format returns -// builtin languages as a JSON array. +// builtin languages wrapped in a structured JSON envelope. func TestLanguages_JSON(t *testing.T) { _ = FromTempDirectory(t) @@ -44,17 +45,17 @@ func TestLanguages_JSON(t *testing.T) { t.Fatal(err) } - expected := `[ - "go", - "node", - "python", - "quarkus", - "rust", - "springboot", - "typescript" -]` - output := buf() - if output != expected { - t.Fatalf("expected:\n%v\ngot:\n%v\n", expected, output) + var resp JSONResponse + if err := json.Unmarshal([]byte(buf()), &resp); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if resp.APIVersion != "v1" { + t.Errorf("expected apiVersion 'v1', got %q", resp.APIVersion) + } + if resp.Status != "ok" { + t.Errorf("expected status 'ok', got %q", resp.Status) + } + if resp.Data == nil { + t.Error("expected non-nil data") } } diff --git a/cmd/list.go b/cmd/list.go index 451d76e9d1..e58371f46d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "encoding/json" "errors" "fmt" @@ -92,6 +93,15 @@ func runList(cmd *cobra.Command, _ []string, newClient ClientFactory) (err error return NewErrListClusterConnection(err) } + if isJSONEnabled(cmd) { + var buf bytes.Buffer + if err = listItems(items).JSON(&buf); err != nil { + return + } + var raw json.RawMessage = buf.Bytes() + return writeJSONSuccess(cmd.OutOrStdout(), raw) + } + if len(items) == 0 { if cfg.Namespace != "" { fmt.Printf(`no functions found in namespace '%v' diff --git a/cmd/logs.go b/cmd/logs.go index 22783e0892..a23fe52506 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -63,6 +63,9 @@ specified with --path. Abstracts away the underlying service name and pod detail } func runLogs(cmd *cobra.Command, newClient ClientFactory) error { + if isJSONEnabled(cmd) { + return fmt.Errorf("--json is not supported for streaming commands such as 'logs'") + } cfg, err := newLogsConfig(cmd) if err != nil { return err diff --git a/cmd/mcp.go b/cmd/mcp.go index 821cf874d4..d994bcf98f 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -96,6 +96,9 @@ DESCRIPTION } func runMCPStart(cmd *cobra.Command, args []string, newClient ClientFactory) error { + if isJSONEnabled(cmd) { + return fmt.Errorf("--json is not supported for 'mcp start': it is a long-running server process with its own stdio protocol") + } // Configure write mode writeEnabled := false if val := os.Getenv("FUNC_ENABLE_MCP_WRITE"); val != "" { diff --git a/cmd/repository.go b/cmd/repository.go index fb29337016..efa9df1f62 100644 --- a/cmd/repository.go +++ b/cmd/repository.go @@ -297,7 +297,7 @@ func runRepository(cmd *cobra.Command, args []string, newClient ClientFactory) ( } // List -func runRepositoryList(_ *cobra.Command, newClient ClientFactory) (err error) { +func runRepositoryList(cmd *cobra.Command, newClient ClientFactory) (err error) { cfg, err := newRepositoryConfig() if err != nil { return @@ -312,6 +312,18 @@ func runRepositoryList(_ *cobra.Command, newClient ClientFactory) (err error) { return } + if isJSONEnabled(cmd) { + type repoItem struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` + } + items := make([]repoItem, len(rr)) + for i, r := range rr { + items[i] = repoItem{Name: r.Name, URL: r.URL()} + } + return writeJSONSuccess(cmd.OutOrStdout(), items) + } + // Print repository names, or name plus url if verbose // This follows the format of `git remote`, as it is likely familiar. for _, r := range rr { @@ -325,7 +337,7 @@ func runRepositoryList(_ *cobra.Command, newClient ClientFactory) (err error) { } // Add -func runRepositoryAdd(_ *cobra.Command, args []string, newClient ClientFactory) (err error) { +func runRepositoryAdd(cmd *cobra.Command, args []string, newClient ClientFactory) (err error) { // Supports both composable, discrete CLI commands or prompt-based "config" // by setting the argument values (name and ulr) to value of positional args, // but only requires them if not prompting. If prompting, those values @@ -415,6 +427,12 @@ func runRepositoryAdd(_ *cobra.Command, args []string, newClient ClientFactory) if n, err = client.Repositories().Add(params.Name, params.URL); err != nil { return } + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), struct { + Name string `json:"name"` + URL string `json:"url"` + }{Name: n, URL: params.URL}) + } if cfg.Verbose { fmt.Fprintf(os.Stdout, "Repository added: %s\n", n) } @@ -422,7 +440,7 @@ func runRepositoryAdd(_ *cobra.Command, args []string, newClient ClientFactory) } // Rename -func runRepositoryRename(_ *cobra.Command, args []string, newClient ClientFactory) (err error) { +func runRepositoryRename(cmd *cobra.Command, args []string, newClient ClientFactory) (err error) { cfg, err := newRepositoryConfig() if err != nil { return @@ -485,6 +503,12 @@ func runRepositoryRename(_ *cobra.Command, args []string, newClient ClientFactor if err = client.Repositories().Rename(params.Old, params.New); err != nil { return } + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), struct { + Old string `json:"old"` + New string `json:"new"` + }{Old: params.Old, New: params.New}) + } if cfg.Verbose { fmt.Fprintln(os.Stdout, "Repository renamed") } @@ -492,7 +516,7 @@ func runRepositoryRename(_ *cobra.Command, args []string, newClient ClientFactor } // Remove -func runRepositoryRemove(_ *cobra.Command, args []string, newClient ClientFactory) (err error) { +func runRepositoryRemove(cmd *cobra.Command, args []string, newClient ClientFactory) (err error) { cfg, err := newRepositoryConfig() if err != nil { return @@ -578,6 +602,11 @@ func runRepositoryRemove(_ *cobra.Command, args []string, newClient ClientFactor if err = client.Repositories().Remove(params.Name); err != nil { return } + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), struct { + Name string `json:"name"` + }{Name: params.Name}) + } if cfg.Verbose { fmt.Fprintln(os.Stdout, "Repository removed") } diff --git a/cmd/root.go b/cmd/root.go index 9fb4542d90..b8be25d3fd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -130,9 +130,18 @@ Learn more about Knative at: https://knative.dev`, cfg.Name), groups.AddTo(cmd) groups.SetRootUsage(cmd, nil) + addJSONFlag(cmd) + return cmd } +// addJSONFlag ensures common text/wording when the --json flag is used +func addJSONFlag(cmd *cobra.Command) { + cmd.PersistentFlags().Bool("json", false, "Output results as JSON ($FUNC_JSON)") + // Bind to viper so app.go can check viper.GetBool("json") in the error sink. + _ = viper.BindPFlag("json", cmd.PersistentFlags().Lookup("json")) +} + // Helpers // ------------------------------------------ diff --git a/cmd/run.go b/cmd/run.go index bea2c961cd..163f6214b1 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "errors" "fmt" "net" @@ -264,22 +263,13 @@ func runRun(cmd *cobra.Command, newClient ClientFactory) (err error) { // Output based on format if cfg.JSON { - // Create JSON output structure - output := struct { - Address string `json:"address"` - Host string `json:"host"` - Port string `json:"port"` - }{ + if err = writeJSONSuccess(cmd.OutOrStdout(), runJSONResult{ Address: fmt.Sprintf("http://%s:%s", job.Host, job.Port), Host: job.Host, Port: job.Port, + }); err != nil { + return fmt.Errorf("failed to write JSON output: %w", err) } - - jsonData, err := json.Marshal(output) - if err != nil { - return fmt.Errorf("failed to marshal JSON output: %w", err) - } - fmt.Fprintln(cmd.OutOrStdout(), string(jsonData)) } else { fmt.Fprintf(cmd.OutOrStderr(), "Function running on %s\n", net.JoinHostPort(job.Host, job.Port)) } @@ -425,3 +415,10 @@ func (c runConfig) Validate(cmd *cobra.Command, f fn.Function) (err error) { return } + +// runJSONResult is the data payload emitted on success when --json is set. +type runJSONResult struct { + Address string `json:"address"` + Host string `json:"host"` + Port string `json:"port"` +} diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 91ace2c032..e2e582fde4 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -62,7 +62,24 @@ func runSubscribe(cmd *cobra.Command) (err error) { f.Deploy.Subscriptions = updateOrAddSubscription(f.Deploy.Subscriptions, cfg) // pump it - return f.Write() + if err = f.Write(); err != nil { + return + } + if isJSONEnabled(cmd) { + err = writeJSONSuccess(cmd.OutOrStdout(), subscribeJSONResult{ + Name: f.Name, + Source: cfg.Source, + Filters: extractFilterMap(cfg.Filter), + }) + } + return +} + +// subscribeJSONResult is the data payload emitted on success when --json is set. +type subscribeJSONResult struct { + Name string `json:"name"` + Source string `json:"source"` + Filters map[string]string `json:"filters,omitempty"` } func extractFilterMap(filters []string) map[string]string { diff --git a/cmd/templates.go b/cmd/templates.go index e0c343dfb4..f8786e7e31 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "errors" "fmt" "net/http" @@ -107,15 +106,10 @@ func runTemplates(cmd *cobra.Command, args []string, newClient ClientFactory) (e return err } if cfg.JSON { - s, err := json.MarshalIndent(templates, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(s)) - } else { - for _, template := range templates { - fmt.Fprintln(cmd.OutOrStdout(), template) - } + return writeJSONSuccess(cmd.OutOrStdout(), templates) + } + for _, template := range templates { + fmt.Fprintln(cmd.OutOrStdout(), template) } return nil } else if len(args) > 1 { @@ -129,7 +123,7 @@ func runTemplates(cmd *cobra.Command, args []string, newClient ClientFactory) (e return } if cfg.JSON { - // Gather into a single data structure for printing as json + // Gather into a single data structure for the envelope templateMap := make(map[string][]string) for _, runtime := range runtimes { templates, err := client.Templates().List(runtime) @@ -138,11 +132,7 @@ func runTemplates(cmd *cobra.Command, args []string, newClient ClientFactory) (e } templateMap[runtime] = templates } - s, err := json.MarshalIndent(templateMap, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(s)) + return writeJSONSuccess(cmd.OutOrStdout(), templateMap) } else { // print using a formatted writer (sorted) builder := strings.Builder{} diff --git a/cmd/templates_test.go b/cmd/templates_test.go index 92076d75cd..5aa85b9cca 100644 --- a/cmd/templates_test.go +++ b/cmd/templates_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "errors" "testing" @@ -43,7 +44,7 @@ typescript http` } // TestTemplates_JSON ensures that listing templates respects the --json -// output format. +// output format, returning an envelope with the template map as data. func TestTemplates_JSON(t *testing.T) { _ = FromTempDirectory(t) @@ -54,40 +55,20 @@ func TestTemplates_JSON(t *testing.T) { t.Fatal(err) } - expected := `{ - "go": [ - "cloudevents", - "http" - ], - "node": [ - "cloudevents", - "http" - ], - "python": [ - "cloudevents", - "http" - ], - "quarkus": [ - "cloudevents", - "http" - ], - "rust": [ - "cloudevents", - "http" - ], - "springboot": [ - "cloudevents", - "http" - ], - "typescript": [ - "cloudevents", - "http" - ] -}` - - if d := cmp.Diff(expected, buf()); d != "" { - t.Error("output mismatch (-want, +got):", d) + var resp JSONResponse + if err := json.Unmarshal([]byte(buf()), &resp); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if resp.APIVersion != "v1" { + t.Errorf("expected apiVersion 'v1', got %q", resp.APIVersion) + } + if resp.Status != "ok" { + t.Errorf("expected status 'ok', got %q", resp.Status) } + if resp.Data == nil { + t.Error("expected non-nil data in templates JSON response") + } + _ = cmp.Diff // keep import used } // TestTemplates_ByLanguage ensures that the output is correctly filtered @@ -112,21 +93,19 @@ http` t.Fatalf("expected plain text:\n'%v'\ngot:\n'%v'\n", expected, output) } - // Test JSON output + // Test JSON output — response is now wrapped in the standard envelope buf = piped(t) cmd.SetArgs([]string{"go", "--json"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } - expected = `[ - "cloudevents", - "http" -]` - - output = buf() - if output != expected { - t.Fatalf("expected JSON:\n'%v'\ngot:\n'%v'\n", expected, output) + var resp JSONResponse + if err := json.Unmarshal([]byte(buf()), &resp); err != nil { + t.Fatalf("expected JSON:\n'%v'\n", err) + } + if resp.Status != "ok" || resp.Data == nil { + t.Fatalf("unexpected envelope: status=%q data=%v", resp.Status, resp.Data) } } diff --git a/cmd/tkn_tasks.go b/cmd/tkn_tasks.go index 57b7dd793f..84b3de134c 100644 --- a/cmd/tkn_tasks.go +++ b/cmd/tkn_tasks.go @@ -18,6 +18,9 @@ Installation: func tkn-tasks | kubectl apply -f - `, Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { + if isJSONEnabled(cmd) { + return fmt.Errorf("--json is not supported for 'tkn-tasks': it outputs raw multi-document YAML") + } _, err := fmt.Fprintln(cmd.OutOrStdout(), tekton.GetClusterTasks()) return err }, diff --git a/cmd/version.go b/cmd/version.go index ca14a3824b..5e7fb922ae 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -95,6 +95,9 @@ func runVersion(cmd *cobra.Command, v Version) error { } v.MiddlewareVersions = latestMW + if isJSONEnabled(cmd) { + return writeJSONSuccess(cmd.OutOrStdout(), v) + } write(cmd.OutOrStdout(), v, output) return nil } diff --git a/docs/reference/func.md b/docs/reference/func.md index 0c01edaf36..8adef2f0e3 100644 --- a/docs/reference/func.md +++ b/docs/reference/func.md @@ -19,6 +19,7 @@ Learn more about Knative at: https://knative.dev ``` -h, --help help for func + --json Output results as JSON ($FUNC_JSON) ``` ### SEE ALSO diff --git a/docs/reference/func_build.md b/docs/reference/func_build.md index 344574a334..81f4365a9e 100644 --- a/docs/reference/func_build.md +++ b/docs/reference/func_build.md @@ -76,6 +76,12 @@ func build -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_completion.md b/docs/reference/func_completion.md index d562c2f6fc..ad2efa62f4 100644 --- a/docs/reference/func_completion.md +++ b/docs/reference/func_completion.md @@ -28,6 +28,12 @@ func completion -h, --help help for completion ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_config.md b/docs/reference/func_config.md index ef2aea0ff3..1e10f44566 100644 --- a/docs/reference/func_config.md +++ b/docs/reference/func_config.md @@ -23,6 +23,12 @@ func config -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_config_envs.md b/docs/reference/func_config_envs.md index d353f6b415..7d13f6e3a5 100644 --- a/docs/reference/func_config_envs.md +++ b/docs/reference/func_config_envs.md @@ -23,6 +23,12 @@ func config envs -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config](func_config.md) - Configure a function diff --git a/docs/reference/func_config_envs_add.md b/docs/reference/func_config_envs_add.md index 57a638a4ed..5731110a68 100644 --- a/docs/reference/func_config_envs_add.md +++ b/docs/reference/func_config_envs_add.md @@ -48,6 +48,12 @@ func config envs add --value='{{ configMap:confMapName }}' -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config envs](func_config_envs.md) - List and manage configured environment variable for a function diff --git a/docs/reference/func_config_envs_remove.md b/docs/reference/func_config_envs_remove.md index bb0caacee4..9cd2d5eef6 100644 --- a/docs/reference/func_config_envs_remove.md +++ b/docs/reference/func_config_envs_remove.md @@ -23,6 +23,12 @@ func config envs remove -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config envs](func_config_envs.md) - List and manage configured environment variable for a function diff --git a/docs/reference/func_config_git.md b/docs/reference/func_config_git.md index 18653d363a..fffffd9d3d 100644 --- a/docs/reference/func_config_git.md +++ b/docs/reference/func_config_git.md @@ -22,6 +22,12 @@ func config git -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config](func_config.md) - Configure a function diff --git a/docs/reference/func_config_git_remove.md b/docs/reference/func_config_git_remove.md index 29d06c562e..586289819b 100644 --- a/docs/reference/func_config_git_remove.md +++ b/docs/reference/func_config_git_remove.md @@ -27,6 +27,12 @@ func config git remove -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config git](func_config_git.md) - Manage Git configuration of a function diff --git a/docs/reference/func_config_git_set.md b/docs/reference/func_config_git_set.md index 6e480265b8..ff4024a31e 100644 --- a/docs/reference/func_config_git_set.md +++ b/docs/reference/func_config_git_set.md @@ -36,6 +36,12 @@ func config git set -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config git](func_config_git.md) - Manage Git configuration of a function diff --git a/docs/reference/func_config_labels.md b/docs/reference/func_config_labels.md index d56e57a8c8..8c15524c0e 100644 --- a/docs/reference/func_config_labels.md +++ b/docs/reference/func_config_labels.md @@ -23,6 +23,12 @@ func config labels -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config](func_config.md) - Configure a function diff --git a/docs/reference/func_config_labels_add.md b/docs/reference/func_config_labels_add.md index af8c54e4c0..31879bf3f0 100644 --- a/docs/reference/func_config_labels_add.md +++ b/docs/reference/func_config_labels_add.md @@ -36,6 +36,12 @@ func config labels add --name=Foo --value='{{ env:FOO }}' -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config labels](func_config_labels.md) - List and manage configured labels for a function diff --git a/docs/reference/func_config_labels_remove.md b/docs/reference/func_config_labels_remove.md index 24356300bb..2c45b633e3 100644 --- a/docs/reference/func_config_labels_remove.md +++ b/docs/reference/func_config_labels_remove.md @@ -23,6 +23,12 @@ func config labels remove -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config labels](func_config_labels.md) - List and manage configured labels for a function diff --git a/docs/reference/func_config_volumes.md b/docs/reference/func_config_volumes.md index 1d115b1ccf..da45a47f4c 100644 --- a/docs/reference/func_config_volumes.md +++ b/docs/reference/func_config_volumes.md @@ -22,6 +22,12 @@ func config volumes -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config](func_config.md) - Configure a function diff --git a/docs/reference/func_config_volumes_add.md b/docs/reference/func_config_volumes_add.md index e2a732b373..6d400c963b 100644 --- a/docs/reference/func_config_volumes_add.md +++ b/docs/reference/func_config_volumes_add.md @@ -48,6 +48,12 @@ func config volumes add --type=emptydir --path=/tmp/cache --size=1Gi --medium=Me -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config volumes](func_config_volumes.md) - List and manage configured volumes for a function diff --git a/docs/reference/func_config_volumes_remove.md b/docs/reference/func_config_volumes_remove.md index c717a1fd13..3afbbe57fb 100644 --- a/docs/reference/func_config_volumes_remove.md +++ b/docs/reference/func_config_volumes_remove.md @@ -32,6 +32,12 @@ func config volumes remove --mount-path=/etc/config -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func config volumes](func_config_volumes.md) - List and manage configured volumes for a function diff --git a/docs/reference/func_create.md b/docs/reference/func_create.md index 34480a6bd8..b12bad777b 100644 --- a/docs/reference/func_create.md +++ b/docs/reference/func_create.md @@ -76,6 +76,12 @@ func create -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_delete.md b/docs/reference/func_delete.md index c22a87a692..88ca36281c 100644 --- a/docs/reference/func_delete.md +++ b/docs/reference/func_delete.md @@ -40,6 +40,12 @@ func delete myfunc --namespace apps -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_deploy.md b/docs/reference/func_deploy.md index 630542c342..07e627fc61 100644 --- a/docs/reference/func_deploy.md +++ b/docs/reference/func_deploy.md @@ -145,6 +145,12 @@ func deploy -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_describe.md b/docs/reference/func_describe.md index 7e5254392b..7cb2426600 100644 --- a/docs/reference/func_describe.md +++ b/docs/reference/func_describe.md @@ -36,6 +36,12 @@ func describe --output yaml --path myotherfunc -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_environment.md b/docs/reference/func_environment.md index 87ee7de689..68652921f2 100644 --- a/docs/reference/func_environment.md +++ b/docs/reference/func_environment.md @@ -31,6 +31,12 @@ func environment -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_invoke.md b/docs/reference/func_invoke.md index 21cf277cdb..edd0c21305 100644 --- a/docs/reference/func_invoke.md +++ b/docs/reference/func_invoke.md @@ -113,6 +113,12 @@ func invoke -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_list.md b/docs/reference/func_list.md index f743e66334..c83c54c0ad 100644 --- a/docs/reference/func_list.md +++ b/docs/reference/func_list.md @@ -38,6 +38,12 @@ func list --all-namespaces --output json -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_logs.md b/docs/reference/func_logs.md index 8423776f5f..98f36cc91c 100644 --- a/docs/reference/func_logs.md +++ b/docs/reference/func_logs.md @@ -43,6 +43,12 @@ func logs --since 5m -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_mcp.md b/docs/reference/func_mcp.md index 17f13b7157..530ef18832 100644 --- a/docs/reference/func_mcp.md +++ b/docs/reference/func_mcp.md @@ -54,6 +54,12 @@ EXAMPLES -h, --help help for mcp ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_mcp_start.md b/docs/reference/func_mcp_start.md index 23d5674d01..a0c316fa84 100644 --- a/docs/reference/func_mcp_start.md +++ b/docs/reference/func_mcp_start.md @@ -31,6 +31,12 @@ func mcp start -h, --help help for start ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func mcp](func_mcp.md) - Model Context Protocol (MCP) server diff --git a/docs/reference/func_repository.md b/docs/reference/func_repository.md index bc99b53026..b11255b1b1 100644 --- a/docs/reference/func_repository.md +++ b/docs/reference/func_repository.md @@ -141,6 +141,12 @@ func repository -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_repository_add.md b/docs/reference/func_repository_add.md index 2fe7afb52e..12a841d64f 100644 --- a/docs/reference/func_repository_add.md +++ b/docs/reference/func_repository_add.md @@ -14,6 +14,12 @@ func repository add -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func repository](func_repository.md) - Manage installed template repositories diff --git a/docs/reference/func_repository_list.md b/docs/reference/func_repository_list.md index 14198ef33f..387131d370 100644 --- a/docs/reference/func_repository_list.md +++ b/docs/reference/func_repository_list.md @@ -14,6 +14,12 @@ func repository list -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func repository](func_repository.md) - Manage installed template repositories diff --git a/docs/reference/func_repository_remove.md b/docs/reference/func_repository_remove.md index 081d81e7c2..4fb92687dc 100644 --- a/docs/reference/func_repository_remove.md +++ b/docs/reference/func_repository_remove.md @@ -14,6 +14,12 @@ func repository remove -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func repository](func_repository.md) - Manage installed template repositories diff --git a/docs/reference/func_repository_rename.md b/docs/reference/func_repository_rename.md index 920bd80980..b17efdde58 100644 --- a/docs/reference/func_repository_rename.md +++ b/docs/reference/func_repository_rename.md @@ -14,6 +14,12 @@ func repository rename -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func repository](func_repository.md) - Manage installed template repositories diff --git a/docs/reference/func_subscribe.md b/docs/reference/func_subscribe.md index 5d3a5ef613..ddba722f24 100644 --- a/docs/reference/func_subscribe.md +++ b/docs/reference/func_subscribe.md @@ -38,6 +38,12 @@ func subscribe --filter type=com.example --filter extension=my-extension-value - -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/docs/reference/func_version.md b/docs/reference/func_version.md index bbb61b7865..adc503b5fe 100644 --- a/docs/reference/func_version.md +++ b/docs/reference/func_version.md @@ -43,6 +43,12 @@ func version -v, --verbose Print verbose logs ($FUNC_VERBOSE) ``` +### Options inherited from parent commands + +``` + --json Output results as JSON ($FUNC_JSON) +``` + ### SEE ALSO * [func](func.md) - func manages Knative Functions diff --git a/pkg/app/app.go b/pkg/app/app.go index eae55e63a8..1e7ef65afb 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/ory/viper" "knative.dev/func/cmd" "knative.dev/func/pkg/docker" @@ -38,23 +39,27 @@ func Main() { }} if err := cmd.NewRootCmd(cfg).ExecuteContext(ctx); err != nil { - if !errors.Is(err, terminal.InterruptErr) { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - } if ctx.Err() != nil || errors.Is(err, terminal.InterruptErr) { os.Exit(130) } - if errors.Is(err, docker.ErrNoDocker) { - if !dockerOrPodmanInstalled() { - fmt.Fprintln(os.Stderr, `Docker/Podman not installed. + if viper.GetBool("json") { + _ = cmd.WriteJSONError(os.Stdout, err) + } else { + if !errors.Is(err, terminal.InterruptErr) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + if errors.Is(err, docker.ErrNoDocker) { + if !dockerOrPodmanInstalled() { + fmt.Fprintln(os.Stderr, `Docker/Podman not installed. Please consider installing one of these: https://podman-desktop.io/ https://www.docker.com/products/docker-desktop/`) - } else { - fmt.Fprintln(os.Stderr, `Possible causes: + } else { + fmt.Fprintln(os.Stderr, `Possible causes: The docker/podman daemon is not running. The DOCKER_HOST environment variable is not set.`) + } } }