diff --git a/cmd/logs.go b/cmd/logs.go new file mode 100644 index 0000000000..22783e0892 --- /dev/null +++ b/cmd/logs.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ory/viper" + "github.com/spf13/cobra" + + "knative.dev/func/pkg/config" + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/knative" +) + +func NewLogsCmd(newClient ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "Stream logs from a deployed function", + Long: `Stream logs from a deployed function + +Streams logs for the function in the current directory or from the directory +specified with --path. Abstracts away the underlying service name and pod details. +`, + Example: ` +# Stream logs for the function in the current directory +{{rootCmdUse}} logs + +# Stream logs for a function by name +{{rootCmdUse}} logs --name my-function + +# Stream logs from a specific namespace +{{rootCmdUse}} logs --namespace my-namespace + +# Stream logs with a specific time window +{{rootCmdUse}} logs --since 5m +`, + SuggestFor: []string{"log", "tail"}, + ValidArgsFunction: CompleteFunctionList, + PreRunE: bindEnv("name", "namespace", "path", "since", "verbose"), + RunE: func(cmd *cobra.Command, args []string) error { + return runLogs(cmd, newClient) + }, + } + + // Config + cfg, err := config.NewDefault() + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "error loading config at '%v'. %v\n", config.File(), err) + } + + // Flags + cmd.Flags().StringP("name", "", "", "Name of the function to get logs from ($FUNC_NAME)") + cmd.Flags().StringP("namespace", "n", defaultNamespace(fn.Function{}, false), "The namespace of the function ($FUNC_NAMESPACE)") + cmd.Flags().StringP("since", "", "1m", "Return logs newer than a relative duration like 5s, 2m, or 3h ($FUNC_LOGS_SINCE)") + addPathFlag(cmd) + addVerboseFlag(cmd, cfg.Verbose) + + return cmd +} + +func runLogs(cmd *cobra.Command, newClient ClientFactory) error { + cfg, err := newLogsConfig(cmd) + if err != nil { + return err + } + + client, done := newClient(ClientConfig{Verbose: cfg.Verbose}) + defer done() + + // Get function details + var f fn.Function + if cfg.Name != "" { + // Get function by name + instance, err := client.Describe(cmd.Context(), cfg.Name, cfg.Namespace, fn.Function{}) + if err != nil { + return fmt.Errorf("failed to get function details: %w", err) + } + f.Name = instance.Name + f.Namespace = instance.Namespace + f.Image = instance.Image + } else { + // Load function from path + f, err = fn.NewFunction(cfg.Path) + if err != nil { + return err + } + if !f.Initialized() { + return NewErrNotInitializedFromPath(f.Root, "logs") + } + + // Get deployed function details to ensure it exists + instance, err := client.Describe(cmd.Context(), "", "", f) + if err != nil { + return fmt.Errorf("function not deployed or not found: %w", err) + } + f.Name = instance.Name + f.Namespace = instance.Namespace + f.Image = instance.Image + } + + // Parse since duration + var sinceTime *time.Time + if cfg.Since != "" { + duration, err := time.ParseDuration(cfg.Since) + if err != nil { + return fmt.Errorf("invalid duration format for --since: %w", err) + } + t := time.Now().Add(-duration) + sinceTime = &t + } + + // Create context that can be cancelled with Ctrl+C + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + // Handle interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Fprintln(os.Stderr, "\nStopping log stream...") + cancel() + }() + + // Stream logs + fmt.Fprintf(os.Stderr, "Streaming logs for function '%s' in namespace '%s'...\n", f.Name, f.Namespace) + fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop.\n\n") + + err = knative.GetKServiceLogs(ctx, f.Namespace, f.Name, f.Image, sinceTime, os.Stdout) + if err != nil && err != context.Canceled { + return fmt.Errorf("failed to stream logs: %w", err) + } + + return nil +} + +// CLI Configuration (parameters) +// ------------------------------ + +type logsConfig struct { + Name string + Namespace string + Path string + Since string + Verbose bool +} + +func newLogsConfig(cmd *cobra.Command) (cfg logsConfig, err error) { + cfg = logsConfig{ + Name: viper.GetString("name"), + Namespace: viper.GetString("namespace"), + Path: viper.GetString("path"), + Since: viper.GetString("since"), + Verbose: viper.GetBool("verbose"), + } + + if cfg.Name != "" && cmd.Flags().Changed("path") { + // logically inconsistent to provide both a name and a path to source. + err = ErrNameAndPathConflict + } + + return +} diff --git a/cmd/logs_test.go b/cmd/logs_test.go new file mode 100644 index 0000000000..2436671d2b --- /dev/null +++ b/cmd/logs_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "testing" + + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/mock" +) + +// TestLogs_CommandStructure ensures the logs command is properly structured +func TestLogs_CommandStructure(t *testing.T) { + describer := mock.NewDescriber() + root := NewRootCmd(RootCommandConfig{ + Name: "func", + NewClient: NewTestClient(fn.WithDescribers(describer)), + }) + + logsCmd, _, err := root.Find([]string{"logs"}) + if err != nil { + t.Fatal(err) + } + + if logsCmd == nil { + t.Fatal("logs command not found") + } + + if logsCmd.Use != "logs" { + t.Errorf("expected Use to be 'logs', got '%s'", logsCmd.Use) + } + + // Check that required flags exist + flags := []string{"name", "namespace", "path", "since", "verbose"} + for _, flag := range flags { + if logsCmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '%s' to exist", flag) + } + } +} + +// TestLogs_ConfigValidation tests the configuration validation +func TestLogs_ConfigValidation(t *testing.T) { + tests := []struct { + name string + cfg logsConfig + wantError bool + }{ + { + name: "valid config with name", + cfg: logsConfig{ + Name: "my-function", + Namespace: "default", + Since: "5m", + }, + wantError: false, + }, + { + name: "valid config with path", + cfg: logsConfig{ + Path: "./testdata", + Namespace: "default", + Since: "1h", + }, + wantError: false, + }, + { + name: "valid config with default since", + cfg: logsConfig{ + Name: "my-function", + Namespace: "default", + Since: "1m", + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Basic validation - just ensure the config structure is valid + if tt.cfg.Name == "" && tt.cfg.Path == "" { + t.Error("config should have either name or path") + } + if tt.cfg.Namespace == "" { + t.Error("namespace should not be empty") + } + }) + } +} + +// TestLogs_SuggestFor ensures the command has proper suggestions +func TestLogs_SuggestFor(t *testing.T) { + describer := mock.NewDescriber() + root := NewRootCmd(RootCommandConfig{ + Name: "func", + NewClient: NewTestClient(fn.WithDescribers(describer)), + }) + + logsCmd, _, err := root.Find([]string{"logs"}) + if err != nil { + t.Fatal(err) + } + + expectedSuggestions := []string{"log", "tail"} + for _, suggestion := range expectedSuggestions { + found := false + for _, s := range logsCmd.SuggestFor { + if s == suggestion { + found = true + break + } + } + if !found { + t.Errorf("expected suggestion '%s' not found", suggestion) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index a34fca3a9f..9fb4542d90 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,6 +96,7 @@ Learn more about Knative at: https://knative.dev`, cfg.Name), NewRunCmd(newClient), NewInvokeCmd(newClient), NewBuildCmd(newClient), + NewLogsCmd(newClient), }, }, { diff --git a/docs/reference/func.md b/docs/reference/func.md index 923f469b0a..0c01edaf36 100644 --- a/docs/reference/func.md +++ b/docs/reference/func.md @@ -34,6 +34,7 @@ Learn more about Knative at: https://knative.dev * [func invoke](func_invoke.md) - Invoke a local or remote function * [func languages](func_languages.md) - List available function language runtimes * [func list](func_list.md) - List deployed functions +* [func logs](func_logs.md) - Stream logs from a deployed function * [func mcp](func_mcp.md) - Model Context Protocol (MCP) server * [func repository](func_repository.md) - Manage installed template repositories * [func run](func_run.md) - Run the function locally diff --git a/docs/reference/func_logs.md b/docs/reference/func_logs.md new file mode 100644 index 0000000000..8423776f5f --- /dev/null +++ b/docs/reference/func_logs.md @@ -0,0 +1,49 @@ +## func logs + +Stream logs from a deployed function + +### Synopsis + +Stream logs from a deployed function + +Streams logs for the function in the current directory or from the directory +specified with --path. Abstracts away the underlying service name and pod details. + + +``` +func logs +``` + +### Examples + +``` + +# Stream logs for the function in the current directory +func logs + +# Stream logs for a function by name +func logs --name my-function + +# Stream logs from a specific namespace +func logs --namespace my-namespace + +# Stream logs with a specific time window +func logs --since 5m + +``` + +### Options + +``` + -h, --help help for logs + --name string Name of the function to get logs from ($FUNC_NAME) + -n, --namespace string The namespace of the function ($FUNC_NAMESPACE) (default "default") + -p, --path string Path to the function. Default is current directory ($FUNC_PATH) + --since string Return logs newer than a relative duration like 5s, 2m, or 3h ($FUNC_LOGS_SINCE) (default "1m") + -v, --verbose Print verbose logs ($FUNC_VERBOSE) +``` + +### SEE ALSO + +* [func](func.md) - func manages Knative Functions +