Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions cmd/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"syscall"
"time"

"github.com/onkernel/cli/pkg/util"
"github.com/onkernel/kernel-go-sdk"
"github.com/onkernel/kernel-go-sdk/option"
"github.com/pterm/pterm"
Expand All @@ -21,17 +22,30 @@ import (
var invokeCmd = &cobra.Command{
Use: "invoke <app_name> <action_name> [flags]",
Short: "Invoke a deployed Kernel application",
Args: cobra.ExactArgs(2),
RunE: runInvoke,
}

var invocationHistoryCmd = &cobra.Command{
Use: "history",
Short: "Show invocation history",
Args: cobra.NoArgs,
RunE: runInvocationHistory,
}

func init() {
invokeCmd.Flags().StringP("version", "v", "latest", "Specify a version of the app to invoke (optional, defaults to 'latest')")
invokeCmd.Flags().StringP("payload", "p", "", "JSON payload for the invocation (optional)")
invokeCmd.Flags().BoolP("sync", "s", false, "Invoke synchronously (default false). A synchronous invocation will open a long-lived HTTP POST to the Kernel API to wait for the invocation to complete. This will time out after 60 seconds, so only use this option if you expect your invocation to complete in less than 60 seconds. The default is to invoke asynchronously, in which case the CLI will open an SSE connection to the Kernel API after submitting the invocation and wait for the invocation to complete.")

invocationHistoryCmd.Flags().Int("limit", 100, "Max invocations to return (default 100)")
invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name")
invokeCmd.AddCommand(invocationHistoryCmd)
}

func runInvoke(cmd *cobra.Command, args []string) error {
if len(args) != 2 {
return fmt.Errorf("requires exactly 2 arguments: <app_name> <action_name>")
}
startTime := time.Now()
client := getKernelClient(cmd)
appName := args[0]
Expand Down Expand Up @@ -70,6 +84,8 @@ func runInvoke(cmd *cobra.Command, args []string) error {
if err != nil {
return handleSdkError(err)
}
// Log the invocation ID for user reference
pterm.Info.Printfln("Invocation ID: %s", resp.ID)
// coordinate the cleanup with the polling loop to ensure this is given enough time to run
// before this function returns
cleanupDone := make(chan struct{})
Expand Down Expand Up @@ -117,7 +133,7 @@ func runInvoke(cmd *cobra.Command, args []string) error {
})

// Start following events
stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, option.WithMaxRetries(0))
stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0))
for stream.Next() {
ev := stream.Current()

Expand Down Expand Up @@ -187,3 +203,75 @@ func printResult(success bool, output string) {
pterm.Error.Printf("Result:\n%s\n", output)
}
}

func runInvocationHistory(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)

lim, _ := cmd.Flags().GetInt("limit")
appFilter, _ := cmd.Flags().GetString("app")

// Build parameters for the API call
params := kernel.InvocationListParams{
Limit: kernel.Opt(int64(lim)),
}

// Only add app filter if specified
if appFilter != "" {
params.AppName = kernel.Opt(appFilter)
pterm.Debug.Printf("Listing invocations for app '%s'...\n", appFilter)
} else {
pterm.Debug.Printf("Listing all invocations...\n")
}

// Make a single API call to get invocations
invocations, err := client.Invocations.List(cmd.Context(), params)
if err != nil {
pterm.Error.Printf("Failed to list invocations: %v\n", err)
return nil
}

table := pterm.TableData{{"Invocation ID", "App Name", "Action", "Status", "Started At", "Duration", "Output"}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app version also useful

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in fact maybe even worth having the capability to filter on it in the API endpoint

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/onkernel/kernel/pull/519

change for including version


for _, inv := range invocations.Items {
started := util.FormatLocal(inv.StartedAt)
status := string(inv.Status)

// Calculate duration
var duration string
if !inv.FinishedAt.IsZero() {
dur := inv.FinishedAt.Sub(inv.StartedAt)
duration = dur.Round(time.Millisecond).String()
} else if status == "running" {
dur := time.Since(inv.StartedAt)
duration = dur.Round(time.Second).String() + " (running)"
} else {
duration = "-"
}

// Truncate output for display
output := inv.Output
if len(output) > 50 {
output = output[:47] + "..."
}
if output == "" {
output = "-"
}

table = append(table, []string{
inv.ID,
inv.AppName,
inv.ActionName,
status,
started,
duration,
output,
})
}

if len(table) == 1 {
pterm.Info.Println("No invocations found.")
} else {
pterm.DefaultTable.WithHasHeader().WithData(table).Render()
}
return nil
}
92 changes: 92 additions & 0 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func init() {
logsCmd.Flags().BoolP("follow", "f", false, "Follow logs in real-time (stream continuously)")
logsCmd.Flags().String("since", "s", "How far back to retrieve logs. Supports duration formats: ns, us, ms, s, m, h (e.g., 5m, 2h, 1h30m). Note: 'd' for days is NOT supported - use hours instead. Can also specify timestamps: 2006-01-02 (day), 2006-01-02T15:04 (minute), 2006-01-02T15:04:05 (second), 2006-01-02T15:04:05.000 (ms). Maximum lookback is 167h (just under 7 days). Defaults to 5m if not following, 5s if following.")
logsCmd.Flags().Bool("with-timestamps", false, "Include timestamps in each log line")
logsCmd.Flags().StringP("invocation", "i", "", "Show logs for a specific invocation/run of the app. Accepts full ID or unambiguous prefix. If the invocation is still running, streaming respects --follow.")
rootCmd.AddCommand(logsCmd)
}

Expand All @@ -34,6 +35,7 @@ func runLogs(cmd *cobra.Command, args []string) error {
follow, _ := cmd.Flags().GetBool("follow")
since, _ := cmd.Flags().GetString("since")
timestamps, _ := cmd.Flags().GetBool("with-timestamps")
invocationRef, _ := cmd.Flags().GetString("invocation")
if version == "" {
version = "latest"
}
Expand All @@ -45,6 +47,90 @@ func runLogs(cmd *cobra.Command, args []string) error {
}
}

// If an invocation is specified, stream invocation-specific logs and return
if invocationRef != "" {
inv, err := client.Invocations.Get(cmd.Context(), invocationRef)
if err != nil {
return fmt.Errorf("failed to get invocation: %w", err)
}
if inv.AppName != appName {
return fmt.Errorf("invocation %s does not belong to app \"%s\" (found app: %s)", inv.ID, appName, inv.AppName)
}

pterm.Info.Printf("Streaming logs for invocation \"%s\" of app \"%s\" (action: %s, status: %s)...\n", inv.ID, inv.AppName, inv.ActionName, inv.Status)
if follow {
pterm.Info.Println("Press Ctrl+C to exit")
} else {
pterm.Info.Println("Showing recent logs (timeout after 3s with no events)")
}

stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0))
if stream.Err() != nil {
return fmt.Errorf("failed to follow streaming: %w", stream.Err())
}

if follow {
for stream.Next() {
data := stream.Current()
switch data.Event {
case "log":
logEntry := data.AsLog()
if timestamps {
fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message)
} else {
fmt.Println(logEntry.Message)
}
case "error":
errEv := data.AsError()
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
}
}
} else {
timeout := time.NewTimer(3 * time.Second)
defer timeout.Stop()

done := false
for !done {
nextCh := make(chan bool, 1)
go func() {
hasNext := stream.Next()
nextCh <- hasNext
}()

select {
case hasNext := <-nextCh:
if !hasNext {
done = true
} else {
data := stream.Current()
switch data.Event {
case "log":
logEntry := data.AsLog()
if timestamps {
fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message)
} else {
fmt.Println(logEntry.Message)
}
case "error":
errEv := data.AsError()
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
}
timeout.Reset(3 * time.Second)
}
case <-timeout.C:
done = true
stream.Close()
return nil
}
}
}

if stream.Err() != nil {
return fmt.Errorf("failed to follow streaming: %w", stream.Err())
}
return nil
}

params := kernel.AppListParams{
AppName: kernel.Opt(appName),
Version: kernel.Opt(version),
Expand Down Expand Up @@ -88,6 +174,9 @@ func runLogs(cmd *cobra.Command, args []string) error {
} else {
fmt.Println(logEntry.Message)
}
case "error":
errEv := data.AsErrorEvent()
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
}
}
} else {
Expand Down Expand Up @@ -122,6 +211,9 @@ func runLogs(cmd *cobra.Command, args []string) error {
} else {
fmt.Println(logEntry.Message)
}
case "error":
errEv := data.AsErrorEvent()
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
}
timeout.Reset(3 * time.Second)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/charmbracelet/fang v0.2.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/joho/godotenv v1.5.1
github.com/onkernel/kernel-go-sdk v0.11.1
github.com/onkernel/kernel-go-sdk v0.11.4
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
github.com/samber/lo v1.51.0
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/onkernel/kernel-go-sdk v0.11.0 h1:7KUKHiz5t4jdnNCwA8NM1dTtEYdk/AV/RIe8T/HjJwg=
github.com/onkernel/kernel-go-sdk v0.11.0/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E=
github.com/onkernel/kernel-go-sdk v0.11.1 h1:gTxhXtsXrJcrM7KEobEVXa8mPPtRFMlxQwNqkyoCrDI=
github.com/onkernel/kernel-go-sdk v0.11.1/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E=
github.com/onkernel/kernel-go-sdk v0.11.4 h1:vgDcPtldfEcRh+a1wlOSOY2bBWjxLFUwHqeXHHQ4OjM=
github.com/onkernel/kernel-go-sdk v0.11.4/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down