diff --git a/commands/commands.go b/commands/commands.go index c35ca39e..5692315f 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -23,6 +23,7 @@ import ( "github.com/fnproject/cli/objects/app" "github.com/fnproject/cli/objects/context" "github.com/fnproject/cli/objects/fn" + "github.com/fnproject/cli/objects/runtime" "github.com/fnproject/cli/objects/server" "github.com/fnproject/cli/objects/trigger" "github.com/urfave/cli" @@ -52,6 +53,7 @@ var Commands = Cmd{ "unset": UnsetCommand(), "update": UpdateCommand(), "use": UseCommand(), + "work-request": WorkRequestCommand(), } var CreateCmds = Cmd{ @@ -96,6 +98,7 @@ var DeleteCmds = Cmd{ var GetCmds = Cmd{ "config": ConfigCommand("get"), + "latest-runtime-version": runtime.GetLatestRuntimeVersion(), } var InspectCmds = Cmd{ @@ -111,6 +114,8 @@ var ListCmds = Cmd{ "functions": fn.List(), "triggers": trigger.List(), "contexts": context.List(), + "runtimes": runtime.ListRuntimes(), + "runtime-versions": runtime.ListRuntimeVersions(), } var UnsetCmds = Cmd{ diff --git a/commands/work_request.go b/commands/work_request.go new file mode 100644 index 00000000..bb8e25f4 --- /dev/null +++ b/commands/work_request.go @@ -0,0 +1,282 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "strings" + + cliClient "github.com/fnproject/cli/client" + appobj "github.com/fnproject/cli/objects/app" + fnobj "github.com/fnproject/cli/objects/fn" + v2Client "github.com/fnproject/fn_go/clientv2" + fnprovider "github.com/fnproject/fn_go/provider/oracle" + ociFunctions "github.com/oracle/oci-go-sdk/v65/functions" + "github.com/urfave/cli" +) + +type workRequestCmd struct { + provider *fnprovider.OracleProvider + clientV2 *v2Client.Fn +} + +type workRequestStatusView struct { + WorkRequestID string + FunctionName string + FunctionID string + Operation string + Status string + Error string + RecentLogs []string +} + +func WorkRequestCommand() cli.Command { + cmd := &workRequestCmd{} + return cli.Command{ + Name: "work-request", + Usage: "\tInspect Functions work requests", + Aliases: []string{"wr"}, + Category: "MANAGEMENT COMMAND", + Before: func(c *cli.Context) error { + provider, err := cliClient.CurrentProvider() + if err != nil { + return err + } + oracleProvider, ok := provider.(*fnprovider.OracleProvider) + if !ok || oracleProvider == nil { + return errors.New("work-request commands require an oracle provider") + } + cmd.provider = oracleProvider + cmd.clientV2 = provider.APIClientv2() + return nil + }, + Subcommands: []cli.Command{ + { + Name: "status", + Usage: "\tShow consolidated status, error, and recent logs for a work request or function", + ArgsUsage: "", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "app", + Usage: "App name when resolving the latest work request for a function name", + }, + cli.IntFlag{ + Name: "log-limit", + Usage: "Number of recent work request logs to show", + Value: 5, + }, + }, + Action: cmd.status, + }, + }, + } +} + +func (w *workRequestCmd) status(c *cli.Context) error { + target := strings.TrimSpace(c.Args().First()) + if target == "" { + return errors.New("work request id or function name is required") + } + logLimit := c.Int("log-limit") + if logLimit <= 0 { + logLimit = 5 + } + workRequestID, functionHint, err := w.resolveWorkRequestTarget(c, target) + if err != nil { + return err + } + view, err := w.loadWorkRequestStatus(workRequestID, functionHint, logLimit) + if err != nil { + return err + } + printWorkRequestStatusView(view) + return nil +} + +func (w *workRequestCmd) resolveWorkRequestTarget(c *cli.Context, target string) (string, string, error) { + if isWorkRequestID(target) { + return target, "", nil + } + appName := strings.TrimSpace(c.String("app")) + if appName == "" { + return "", "", errors.New("--app is required when resolving a function name to its latest work request") + } + appRes, err := appobj.GetAppByName(w.clientV2, appName) + if err != nil { + return "", "", err + } + fnRes, err := fnobj.GetFnByName(w.clientV2, appRes.ID, target) + if err != nil { + return "", "", err + } + wrClient, err := buildCLIWorkRequestClient(w.provider) + if err != nil { + return "", "", err + } + limit := 1 + resp, err := wrClient.ListWorkRequests(context.Background(), ociFunctions.ListWorkRequestsRequest{ + CompartmentId: &w.provider.CompartmentID, + ResourceId: &fnRes.ID, + Limit: &limit, + SortBy: ociFunctions.ListWorkRequestsSortByTimeaccepted, + SortOrder: ociFunctions.ListWorkRequestsSortOrderDesc, + }) + if err != nil { + return "", "", err + } + if len(resp.Items) == 0 || resp.Items[0].Id == nil { + return "", "", fmt.Errorf("no work requests found for function %s", target) + } + return *resp.Items[0].Id, fnRes.Name, nil +} + +func isWorkRequestID(value string) bool { + lower := strings.ToLower(strings.TrimSpace(value)) + return strings.HasPrefix(lower, "ocid1.") && strings.Contains(lower, "workrequest.") +} + +func (w *workRequestCmd) loadWorkRequestStatus(workRequestID, functionHint string, logLimit int) (*workRequestStatusView, error) { + wrClient, err := buildCLIWorkRequestClient(w.provider) + if err != nil { + return nil, err + } + resp, err := wrClient.GetWorkRequest(context.Background(), ociFunctions.GetWorkRequestRequest{WorkRequestId: &workRequestID}) + if err != nil { + return nil, err + } + view := &workRequestStatusView{ + WorkRequestID: workRequestID, + Operation: simplifyWorkRequestOperation(resp.OperationType), + Status: string(resp.Status), + FunctionName: functionHint, + } + functionID := extractFunctionResourceID(resp.WorkRequest) + view.FunctionID = functionID + if view.FunctionName == "" && functionID != "" { + if name, err := lookupFunctionName(w.provider, functionID); err == nil { + view.FunctionName = name + } + } + if view.FunctionName == "" && functionID != "" { + view.FunctionName = functionID + } + + if errResp, err := wrClient.ListWorkRequestErrors(context.Background(), ociFunctions.ListWorkRequestErrorsRequest{ + WorkRequestId: &workRequestID, + Limit: intPointer(1), + SortBy: ociFunctions.ListWorkRequestErrorsSortByTimestamp, + SortOrder: ociFunctions.ListWorkRequestErrorsSortOrderDesc, + }); err == nil && len(errResp.Items) > 0 && errResp.Items[0].Message != nil { + view.Error = *errResp.Items[0].Message + } + + logsResp, err := wrClient.ListWorkRequestLogs(context.Background(), ociFunctions.ListWorkRequestLogsRequest{ + WorkRequestId: &workRequestID, + Limit: intPointer(logLimit), + SortBy: ociFunctions.ListWorkRequestLogsSortByTimestamp, + SortOrder: ociFunctions.ListWorkRequestLogsSortOrderDesc, + }) + if err == nil && len(logsResp.Items) > 0 { + for i := len(logsResp.Items) - 1; i >= 0; i-- { + if logsResp.Items[i].Message != nil { + view.RecentLogs = append(view.RecentLogs, *logsResp.Items[i].Message) + } + } + } + + return view, nil +} + +func buildCLIWorkRequestClient(provider *fnprovider.OracleProvider) (*ociFunctions.WorkRequestManagementClient, error) { + client, err := ociFunctions.NewWorkRequestManagementClientWithConfigurationProvider(provider.ConfigurationProvider) + if err != nil { + return nil, err + } + if provider.FnApiUrl != nil { + client.Host = provider.FnApiUrl.String() + } else if region := getRegion(provider); region != "" { + client.SetRegion(region) + } + return &client, nil +} + +func buildCLIFunctionsManagementClient(provider *fnprovider.OracleProvider) (*ociFunctions.FunctionsManagementClient, error) { + client, err := ociFunctions.NewFunctionsManagementClientWithConfigurationProvider(provider.ConfigurationProvider) + if err != nil { + return nil, err + } + if provider.FnApiUrl != nil { + client.Host = provider.FnApiUrl.String() + } else if region := getRegion(provider); region != "" { + client.SetRegion(region) + } + return &client, nil +} + +func lookupFunctionName(provider *fnprovider.OracleProvider, functionID string) (string, error) { + client, err := buildCLIFunctionsManagementClient(provider) + if err != nil { + return "", err + } + resp, err := client.GetFunction(context.Background(), ociFunctions.GetFunctionRequest{FunctionId: &functionID}) + if err != nil { + return "", err + } + if resp.DisplayName == nil { + return "", errors.New("function display name not found") + } + return *resp.DisplayName, nil +} + +func extractFunctionResourceID(workRequest ociFunctions.WorkRequest) string { + for _, resource := range workRequest.Resources { + entityType := "" + if resource.EntityType != nil { + entityType = strings.ToLower(strings.TrimSpace(*resource.EntityType)) + } + if strings.Contains(entityType, "function") && resource.Identifier != nil { + return *resource.Identifier + } + } + for _, resource := range workRequest.Resources { + if resource.Identifier != nil { + return *resource.Identifier + } + } + return "" +} + +func simplifyWorkRequestOperation(operation ociFunctions.OperationTypeEnum) string { + value := strings.TrimSpace(string(operation)) + if value == "" { + return "UNKNOWN" + } + if idx := strings.Index(value, "_"); idx != -1 { + return value[:idx] + } + return value +} + +func printWorkRequestStatusView(view *workRequestStatusView) { + fmt.Printf("Work Request: %s\n", view.WorkRequestID) + if view.FunctionName != "" { + fmt.Printf("Function: %s\n", view.FunctionName) + } + if view.Operation != "" { + fmt.Printf("Operation: %s\n", view.Operation) + } + fmt.Printf("Status: %s\n", view.Status) + if view.Error != "" { + fmt.Printf("Error: %s\n", view.Error) + } + if len(view.RecentLogs) > 0 { + fmt.Println("Recent Logs:") + for _, entry := range view.RecentLogs { + fmt.Printf("- %s\n", entry) + } + } +} + +func intPointer(v int) *int { + return &v +} \ No newline at end of file diff --git a/commands/work_request_test.go b/commands/work_request_test.go new file mode 100644 index 00000000..76fb1c74 --- /dev/null +++ b/commands/work_request_test.go @@ -0,0 +1,171 @@ +package commands + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + ociFunctions "github.com/oracle/oci-go-sdk/v65/functions" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdout pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + outC := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + outC <- buf.String() + }() + + fn() + _ = w.Close() + output := <-outC + _ = r.Close() + return output +} + +func TestIsWorkRequestID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "valid functions work request ocid", input: "ocid1.functionsworkrequest.oc1..exampleuniqueID", want: true}, + {name: "valid mixed case trimmed", input: " OCID1.FunctionsWorkRequest.oc1..exampleuniqueID ", want: true}, + {name: "normal function name", input: "hello", want: false}, + {name: "plain ocid without workrequest", input: "ocid1.fnfunc.oc1..exampleuniqueID", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isWorkRequestID(tc.input) + if got != tc.want { + t.Fatalf("isWorkRequestID(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestSimplifyWorkRequestOperation(t *testing.T) { + tests := []struct { + name string + input ociFunctions.OperationTypeEnum + want string + }{ + {name: "create function", input: ociFunctions.OperationTypeEnum("CREATE_FUNCTION"), want: "CREATE"}, + {name: "update function", input: ociFunctions.OperationTypeEnum("UPDATE_FUNCTION"), want: "UPDATE"}, + {name: "blank", input: ociFunctions.OperationTypeEnum(""), want: "UNKNOWN"}, + {name: "single token", input: ociFunctions.OperationTypeEnum("DELETE"), want: "DELETE"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := simplifyWorkRequestOperation(tc.input) + if got != tc.want { + t.Fatalf("simplifyWorkRequestOperation(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestExtractFunctionResourceID(t *testing.T) { + functionEntityType := "function" + functionID := "ocid1.fnfunc.oc1..exampleuniqueID" + otherEntityType := "application" + otherID := "ocid1.fnapp.oc1..otherID" + + t.Run("prefers function resource identifiers", func(t *testing.T) { + wr := ociFunctions.WorkRequest{ + Resources: []ociFunctions.WorkRequestResource{ + {EntityType: &otherEntityType, Identifier: &otherID}, + {EntityType: &functionEntityType, Identifier: &functionID}, + }, + } + got := extractFunctionResourceID(wr) + if got != functionID { + t.Fatalf("extractFunctionResourceID() = %q, want %q", got, functionID) + } + }) + + t.Run("falls back to first identifier if no function entity exists", func(t *testing.T) { + wr := ociFunctions.WorkRequest{ + Resources: []ociFunctions.WorkRequestResource{ + {EntityType: &otherEntityType, Identifier: &otherID}, + }, + } + got := extractFunctionResourceID(wr) + if got != otherID { + t.Fatalf("extractFunctionResourceID() = %q, want %q", got, otherID) + } + }) + + t.Run("returns empty when no identifiers exist", func(t *testing.T) { + wr := ociFunctions.WorkRequest{} + got := extractFunctionResourceID(wr) + if got != "" { + t.Fatalf("extractFunctionResourceID() = %q, want empty string", got) + } + }) +} + +func TestPrintWorkRequestStatusView(t *testing.T) { + view := &workRequestStatusView{ + WorkRequestID: "ocid1.functionsworkrequest.oc1..exampleuniqueID", + FunctionName: "hello", + Operation: "CREATE", + Status: "SUCCEEDED", + Error: "", + RecentLogs: []string{"accepted", "completed"}, + } + + output := captureStdout(t, func() { + printWorkRequestStatusView(view) + }) + + checks := []string{ + "Work Request: ocid1.functionsworkrequest.oc1..exampleuniqueID", + "Function: hello", + "Operation: CREATE", + "Status: SUCCEEDED", + "Recent Logs:", + "- accepted", + "- completed", + } + + for _, check := range checks { + if !strings.Contains(output, check) { + t.Fatalf("expected output to contain %q, got: %s", check, output) + } + } +} + +func TestWorkRequestCommandRegistration(t *testing.T) { + cmd := WorkRequestCommand() + if cmd.Name != "work-request" { + t.Fatalf("command name = %q, want %q", cmd.Name, "work-request") + } + if len(cmd.Subcommands) == 0 { + t.Fatal("expected work-request command to have subcommands") + } + if cmd.Subcommands[0].Name != "status" { + t.Fatalf("first subcommand name = %q, want %q", cmd.Subcommands[0].Name, "status") + } + + registered, ok := Commands["work-request"] + if !ok { + t.Fatal("expected work-request command to be registered in Commands map") + } + if registered.Name != "work-request" { + t.Fatalf("registered command name = %q, want %q", registered.Name, "work-request") + } +} \ No newline at end of file