Skip to content

Commit 7842ba3

Browse files
committed
Add --json/-j to most get/list commands
For the sake of agent-friendliness - adds structured output for consumption from ad-hoc bash/Python scripts, direct parsing etc. Tested by Qwen 3.6 27B against openfaas edge installation to show that the original did not revert, and the new behaviour is in place. Signed-off-by: Alex Ellis (OpenFaaS Ltd) <alexellis2@gmail.com>
1 parent 45e2f19 commit 7842ba3

18 files changed

Lines changed: 577 additions & 80 deletions

commands/describe.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package commands
55

66
import (
77
"context"
8+
"encoding/json"
89
"fmt"
910
"io"
1011
"os"
@@ -30,17 +31,19 @@ func init() {
3031
describeCmd.Flags().StringVarP(&token, "token", "k", "", "Pass a JWT token to use instead of basic auth")
3132
describeCmd.Flags().StringVarP(&functionNamespace, "namespace", "n", "", "Namespace of the function")
3233
describeCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
34+
describeCmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "Output function details as JSON")
3335

3436
faasCmd.AddCommand(describeCmd)
3537
}
3638

3739
var describeCmd = &cobra.Command{
38-
Use: "describe FUNCTION_NAME [--gateway GATEWAY_URL]",
40+
Use: "describe FUNCTION_NAME [--gateway GATEWAY_URL] [--json]",
3941
Short: "Describe an OpenFaaS function",
4042
Long: `Display details of an OpenFaaS function`,
41-
Example: `faas-cli describe figlet
42-
faas-cli describe env --gateway http://127.0.0.1:8080
43-
faas-cli describe echo -g http://127.0.0.1.8080`,
43+
Example: ` faas-cli describe figlet
44+
faas-cli describe env --gateway http://127.0.0.1:8080
45+
faas-cli describe echo -g http://127.0.0.1.8080
46+
faas-cli describe env --json`,
4447
PreRunE: preRunDescribe,
4548
RunE: runDescribe,
4649
}
@@ -115,7 +118,15 @@ func runDescribe(cmd *cobra.Command, args []string) error {
115118
AsyncURL: asyncURL,
116119
}
117120

118-
printFunctionDescription(cmd.OutOrStdout(), funcDesc, verbose)
121+
if jsonOutput {
122+
data, err := json.MarshalIndent(funcDesc, "", " ")
123+
if err != nil {
124+
return fmt.Errorf("failed to marshal JSON: %w", err)
125+
}
126+
fmt.Fprintln(cmd.OutOrStdout(), string(data))
127+
} else {
128+
printFunctionDescription(cmd.OutOrStdout(), funcDesc, verbose)
129+
}
119130

120131
return nil
121132
}

commands/generate.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ var generateCmd = &cobra.Command{
6060
Use: "generate --api=openfaas.com/v1 --yaml stack.yaml --tag sha --namespace=openfaas-fn",
6161
Short: "Generate Kubernetes CRD YAML file",
6262
Long: `The generate command creates kubernetes CRD YAML file for functions`,
63-
Example: `faas-cli generate --api=openfaas.com/v1 --yaml stack.yaml | kubectl apply -f -
64-
faas-cli generate --api=openfaas.com/v1 -f stack.yaml
65-
faas-cli generate --api=serving.knative.dev/v1 -f stack.yaml
66-
faas-cli generate --api=openfaas.com/v1 --namespace openfaas-fn -f stack.yaml
67-
faas-cli generate --api=openfaas.com/v1 -f stack.yaml --tag branch -n openfaas-fn`,
63+
Example: ` faas-cli generate --api=openfaas.com/v1 --yaml stack.yaml | kubectl apply -f -
64+
faas-cli generate --api=openfaas.com/v1 -f stack.yaml
65+
faas-cli generate --api=serving.knative.dev/v1 -f stack.yaml
66+
faas-cli generate --api=openfaas.com/v1 --namespace openfaas-fn -f stack.yaml
67+
faas-cli generate --api=openfaas.com/v1 -f stack.yaml --tag branch -n openfaas-fn`,
6868
PreRunE: preRunGenerate,
6969
RunE: runGenerate,
7070
}

commands/json_output_test.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"net"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"strings"
12+
"testing"
13+
14+
"github.com/openfaas/faas-cli/flags"
15+
storeV2 "github.com/openfaas/faas-cli/schema/store/v2"
16+
"github.com/openfaas/faas-provider/logs"
17+
types "github.com/openfaas/faas-provider/types"
18+
"github.com/spf13/cobra"
19+
)
20+
21+
func TestSecretListJSONEmptyResultUsesJSONStdout(t *testing.T) {
22+
resetJSONCommandTestState(t)
23+
24+
s := newInsecureWarningHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
if r.URL.Path != "/system/secrets" {
26+
t.Fatalf("expected /system/secrets, got %s", r.URL.Path)
27+
}
28+
29+
w.Header().Set("Content-Type", "application/json")
30+
w.WriteHeader(http.StatusOK)
31+
_, _ = w.Write([]byte(`[]`))
32+
}))
33+
34+
gateway = s.URL
35+
jsonOutput = true
36+
37+
stdout, stderr := captureStdoutStderr(t, func() {
38+
cmd := &cobra.Command{}
39+
cmd.SetOut(os.Stdout)
40+
41+
if err := runSecretList(cmd, nil); err != nil {
42+
t.Fatalf("runSecretList returned error: %s", err)
43+
}
44+
})
45+
46+
if strings.Contains(stdout, "No secrets found") {
47+
t.Fatalf("expected JSON stdout, got text output: %q", stdout)
48+
}
49+
if strings.Contains(stdout, NoTLSWarn) {
50+
t.Fatalf("expected TLS warning off stdout, got: %q", stdout)
51+
}
52+
if strings.Contains(stderr, NoTLSWarn) {
53+
t.Fatalf("expected TLS warning omitted for JSON output, got stderr: %q", stderr)
54+
}
55+
56+
var secrets []types.Secret
57+
if err := json.Unmarshal([]byte(stdout), &secrets); err != nil {
58+
t.Fatalf("expected valid JSON stdout, got %q: %s", stdout, err)
59+
}
60+
if len(secrets) != 0 {
61+
t.Fatalf("expected empty secret list, got %d entries", len(secrets))
62+
}
63+
}
64+
65+
func TestSecretListTextEmptyResultKeepsWarningVisible(t *testing.T) {
66+
resetJSONCommandTestState(t)
67+
68+
s := newInsecureWarningHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+
w.Header().Set("Content-Type", "application/json")
70+
w.WriteHeader(http.StatusOK)
71+
_, _ = w.Write([]byte(`[]`))
72+
}))
73+
74+
gateway = s.URL
75+
76+
stdout, stderr := captureStdoutStderr(t, func() {
77+
if err := runSecretList(&cobra.Command{}, nil); err != nil {
78+
t.Fatalf("runSecretList returned error: %s", err)
79+
}
80+
})
81+
82+
if !strings.Contains(stdout, "No secrets found.") {
83+
t.Fatalf("expected empty text message on stdout, got: %q", stdout)
84+
}
85+
if !strings.Contains(stdout, NoTLSWarn) {
86+
t.Fatalf("expected TLS warning on stdout, got: %q", stdout)
87+
}
88+
if strings.Contains(stderr, NoTLSWarn) {
89+
t.Fatalf("expected no TLS warning on stderr, got: %q", stderr)
90+
}
91+
}
92+
93+
func TestStoreListJSONEmptyFilterUsesJSONStdout(t *testing.T) {
94+
resetJSONCommandTestState(t)
95+
96+
s := newInsecureWarningHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97+
w.Header().Set("Content-Type", "application/json")
98+
w.WriteHeader(http.StatusOK)
99+
_, _ = w.Write([]byte(`{
100+
"version": "1.0",
101+
"functions": [
102+
{
103+
"title": "NodeInfo",
104+
"name": "nodeinfo",
105+
"images": {
106+
"x86_64": "functions/nodeinfo:latest"
107+
}
108+
}
109+
]
110+
}`))
111+
}))
112+
113+
storeAddress = s.URL
114+
platformValue = "missing"
115+
jsonOutput = true
116+
117+
stdout, _ := captureStdoutStderr(t, func() {
118+
cmd := &cobra.Command{}
119+
cmd.SetOut(os.Stdout)
120+
121+
if err := runStoreList(cmd, nil); err != nil {
122+
t.Fatalf("runStoreList returned error: %s", err)
123+
}
124+
})
125+
126+
if strings.Contains(stdout, "No functions found") {
127+
t.Fatalf("expected JSON stdout, got text output: %q", stdout)
128+
}
129+
130+
var functions []storeV2.StoreFunction
131+
if err := json.Unmarshal([]byte(stdout), &functions); err != nil {
132+
t.Fatalf("expected valid JSON stdout, got %q: %s", stdout, err)
133+
}
134+
if len(functions) != 0 {
135+
t.Fatalf("expected empty store list, got %d entries", len(functions))
136+
}
137+
}
138+
139+
func TestLogsJSONOmitsTLSWarning(t *testing.T) {
140+
resetJSONCommandTestState(t)
141+
142+
s := newInsecureWarningHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+
if r.URL.Path != "/system/logs" {
144+
t.Fatalf("expected /system/logs, got %s", r.URL.Path)
145+
}
146+
147+
w.Header().Set("Content-Type", "application/x-ndjson")
148+
w.WriteHeader(http.StatusOK)
149+
_ = json.NewEncoder(w).Encode(logs.Message{Name: "fn", Text: "hello"})
150+
}))
151+
152+
gateway = s.URL
153+
jsonOutput = true
154+
logFlagValues.timeFormat = flags.TimeFormat("")
155+
logFlagValues.tail = false
156+
logFlagValues.lines = -1
157+
158+
stdout, stderr := captureStdoutStderr(t, func() {
159+
cmd := newLogsTestCommand()
160+
161+
if err := runLogs(cmd, []string{"fn"}); err != nil {
162+
t.Fatalf("runLogs returned error: %s", err)
163+
}
164+
})
165+
166+
if strings.Contains(stdout, NoTLSWarn) {
167+
t.Fatalf("expected TLS warning off stdout, got: %q", stdout)
168+
}
169+
if strings.Contains(stderr, NoTLSWarn) {
170+
t.Fatalf("expected TLS warning omitted for JSON output, got stderr: %q", stderr)
171+
}
172+
173+
var msg logs.Message
174+
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &msg); err != nil {
175+
t.Fatalf("expected valid JSON log stdout, got %q: %s", stdout, err)
176+
}
177+
if msg.Text != "hello" {
178+
t.Fatalf("expected log text %q, got %q", "hello", msg.Text)
179+
}
180+
}
181+
182+
func TestLogsTextWarningUsesStdout(t *testing.T) {
183+
resetJSONCommandTestState(t)
184+
185+
s := newInsecureWarningHTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186+
w.Header().Set("Content-Type", "application/x-ndjson")
187+
w.WriteHeader(http.StatusOK)
188+
_ = json.NewEncoder(w).Encode(logs.Message{Name: "fn", Text: "hello"})
189+
}))
190+
191+
gateway = s.URL
192+
logFlagValues.timeFormat = flags.TimeFormat("")
193+
logFlagValues.tail = false
194+
logFlagValues.lines = -1
195+
196+
stdout, stderr := captureStdoutStderr(t, func() {
197+
cmd := newLogsTestCommand()
198+
199+
if err := runLogs(cmd, []string{"fn"}); err != nil {
200+
t.Fatalf("runLogs returned error: %s", err)
201+
}
202+
})
203+
204+
if !strings.Contains(stdout, "hello") {
205+
t.Fatalf("expected log output on stdout, got: %q", stdout)
206+
}
207+
if !strings.Contains(stdout, NoTLSWarn) {
208+
t.Fatalf("expected TLS warning on stdout, got: %q", stdout)
209+
}
210+
if strings.Contains(stderr, NoTLSWarn) {
211+
t.Fatalf("expected no TLS warning on stderr, got: %q", stderr)
212+
}
213+
}
214+
215+
func resetJSONCommandTestState(t *testing.T) {
216+
t.Helper()
217+
218+
reset := func() {
219+
resetForTest()
220+
jsonOutput = false
221+
gateway = defaultGateway
222+
tlsInsecure = false
223+
token = ""
224+
functionNamespace = ""
225+
storeAddress = defaultStore
226+
platformValue = ""
227+
verbose = true
228+
logFlagValues = logFlags{}
229+
}
230+
231+
reset()
232+
233+
t.Setenv("NO_PROXY", "*")
234+
t.Setenv("no_proxy", "*")
235+
236+
t.Cleanup(reset)
237+
}
238+
239+
func newLogsTestCommand() *cobra.Command {
240+
cmd := &cobra.Command{}
241+
cmd.Flags().String("namespace", "", "")
242+
return cmd
243+
}
244+
245+
func newInsecureWarningHTTPServer(t *testing.T, handler http.Handler) *httptest.Server {
246+
t.Helper()
247+
248+
listener, err := net.Listen("tcp4", "127.0.0.2:0")
249+
if err != nil {
250+
t.Fatalf("listen on 127.0.0.2:0: %s", err)
251+
}
252+
253+
s := httptest.NewUnstartedServer(handler)
254+
s.Listener = listener
255+
s.Start()
256+
257+
t.Cleanup(s.Close)
258+
259+
return s
260+
}
261+
262+
func captureStdoutStderr(t *testing.T, f func()) (string, string) {
263+
t.Helper()
264+
265+
stdout := os.Stdout
266+
stderr := os.Stderr
267+
268+
outReader, outWriter, err := os.Pipe()
269+
if err != nil {
270+
t.Fatalf("create stdout pipe: %s", err)
271+
}
272+
errReader, errWriter, err := os.Pipe()
273+
if err != nil {
274+
t.Fatalf("create stderr pipe: %s", err)
275+
}
276+
277+
os.Stdout = outWriter
278+
os.Stderr = errWriter
279+
280+
defer func() {
281+
os.Stdout = stdout
282+
os.Stderr = stderr
283+
_ = outReader.Close()
284+
_ = errReader.Close()
285+
}()
286+
287+
f()
288+
289+
_ = outWriter.Close()
290+
_ = errWriter.Close()
291+
292+
var out bytes.Buffer
293+
var errOut bytes.Buffer
294+
_, _ = io.Copy(&out, outReader)
295+
_, _ = io.Copy(&errOut, errReader)
296+
297+
return out.String(), errOut.String()
298+
}

0 commit comments

Comments
 (0)