diff --git a/.releaserc b/.releaserc index e665d6f..013ec31 100644 --- a/.releaserc +++ b/.releaserc @@ -1,12 +1,12 @@ { "branches": [ - { - "name": "dev", - "prerelease": true - }, { "name": "main", "prerelease": false + }, + { + "name": "next", + "prerelease": true } ], "plugins": [ diff --git a/cmd/admin_project/update.go b/cmd/admin_project/update.go new file mode 100644 index 0000000..68bedc1 --- /dev/null +++ b/cmd/admin_project/update.go @@ -0,0 +1,75 @@ +package admin_project + +import ( + "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" + adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" + cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "github.com/G-PORTAL/gpcore-cli/pkg/client" + "github.com/G-PORTAL/gpcore-cli/pkg/config" + "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" + "github.com/spf13/cobra" + "google.golang.org/grpc" +) + +// update is implemented manually (not generated) because +// admin.UpdateProjectRequest carries a nested BillingProfile message which the +// YAML generator cannot express as command flags. The billing profile is +// referenced by its UUID; the backend resolves the full profile from the ID. + +var updateId string +var updateName string +var updateAvatarUrl string +var updateBillingProfileId string +var updateServerPoolIds []string + +var updateCmd = &cobra.Command{ + Args: cobra.OnlyValidArgs, + DisableFlagsInUseLine: true, + Long: "Update project details (admin)", + RunE: func(cobraCmd *cobra.Command, args []string) error { + ctx := client.ExtractContext(cobraCmd) + grpcConn := ctx.Value("conn").(*grpc.ClientConn) + grpcClient := adminv1grpc.NewAdminServiceClient(grpcConn) + + req := &adminv1.UpdateProjectRequest{ + Id: updateId, + Name: updateName, + AvatarUrl: updateAvatarUrl, + ServerPoolIds: updateServerPoolIds, + } + if updateBillingProfileId != "" { + req.BillingProfile = cloudv1.BillingProfile_builder{ + Id: updateBillingProfileId, + }.Build() + } + + resp, err := grpcClient.UpdateProject(cobraCmd.Context(), req) + if err != nil { + return err + } + if config.JSONOutput { + jsonData, err := protobuf.MarshalIndent(resp) + if err != nil { + return err + } + cobraCmd.Println(string(jsonData)) + } + return nil + }, + Short: "Update project details (admin)", + Use: "update", + ValidArgs: []string{"id", "name", "avatar-url", "billing-profile-id", "server-pool-ids"}, +} + +func init() { + updateCmd.Flags().StringVar(&updateId, "id", "", "Project UUID (required)") + updateCmd.Flags().StringVar(&updateName, "name", "", "Project name (required)") + updateCmd.Flags().StringVar(&updateAvatarUrl, "avatar-url", "", "Avatar URL") + updateCmd.Flags().StringVar(&updateBillingProfileId, "billing-profile-id", "", "Billing profile UUID") + updateCmd.Flags().StringSliceVar(&updateServerPoolIds, "server-pool-ids", nil, "Server pool UUIDs") + + updateCmd.MarkFlagRequired("id") + updateCmd.MarkFlagRequired("name") + + RootAdminProjectCommand.AddCommand(updateCmd) +} diff --git a/cmd/agent/root.go b/cmd/agent/root.go index dec2024..be4e79c 100644 --- a/cmd/agent/root.go +++ b/cmd/agent/root.go @@ -63,6 +63,7 @@ func New() *cobra.Command { cmd.SelfupdateCommand(&rootCmd) cmd.SetLogLevelCommand(&rootCmd) cmd.LiveLogCommand(&rootCmd) + cmd.NotificationsCommand(&rootCmd) //InteractiveCLICommand(&rootCmd) // Autogenerated commands diff --git a/cmd/agent/start.go b/cmd/agent/start.go index ab98a2b..d5f2dcb 100644 --- a/cmd/agent/start.go +++ b/cmd/agent/start.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/G-PORTAL/gpcore-cli/pkg/api" + "github.com/G-PORTAL/gpcore-cli/pkg/client" "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/G-PORTAL/gpcore-cli/pkg/consts" "github.com/charmbracelet/log" @@ -71,7 +72,7 @@ var startCmd = &cobra.Command{ if err := rootCmd.ExecuteContext(ctx); err != nil { log.Errorf("Error executing command on agent: %v", err) - rootCmd.Printf("Error executing command on agent: %v\n", err) + rootCmd.Printf("Error: %s\n", client.FormatCommandError(err)) _ = s.Exit(1) // send cmd exit code to the client return } diff --git a/cmd/livelog.go b/cmd/livelog.go index ade33f6..7a1afbd 100644 --- a/cmd/livelog.go +++ b/cmd/livelog.go @@ -4,7 +4,7 @@ import ( "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" - "github.com/charmbracelet/ssh" + "github.com/G-PORTAL/gpcore-cli/pkg/api" "github.com/jedib0t/go-pretty/v6/text" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -18,37 +18,16 @@ func LiveLogCommand(rootCmd *cobra.Command) { DisableFlagsInUseLine: true, Args: cobra.OnlyValidArgs, RunE: func(cobraCmd *cobra.Command, args []string) error { - sshSession := cobraCmd.Context().Value("ssh").(*ssh.Session) - cobraCmd.SetOut(*sshSession) - conn := cobraCmd.Context().Value("conn").(*grpc.ClientConn) admin := adminv1grpc.NewAdminServiceClient(conn) - res, err := admin.SubscribeServerLogs(cobraCmd.Context(), &adminv1.SubscribeServerLogsRequest{}) + stream, err := admin.SubscribeServerLogs(cobraCmd.Context(), &adminv1.SubscribeServerLogsRequest{}) if err != nil { return err } - connectionClosed := false - go func() { - breakChan := make(chan bool) - (*sshSession).Break(breakChan) - <-breakChan - connectionClosed = true - }() - - cobraCmd.Printf("\033[33mWaiting for new notifications ...\033[0m\n") // We wait for (and print out) relevant notifications until the break // request is received. - for { - if connectionClosed { - break - } - - msg, err := res.Recv() // Blocking - if err != nil { - return err - } - + return api.StreamMessages(cobraCmd, stream, func(msg *adminv1.SubscribeServerLogsResponse) { // TODO: Filter for source/server/datacenter // TODO: Only above level ... @@ -78,9 +57,7 @@ func LiveLogCommand(rootCmd *cobra.Command) { cobraCmd.Printf("%s: [%s] [%s] [%s] -> %s\n", color.Sprint(time), datacenter, server, source, color.Sprint(m.GetMessage())) } - } - - return nil + }) }, }) } diff --git a/cmd/node/change_rescue_mode.go b/cmd/node/change_rescue_mode.go index 11c44e5..87666da 100644 --- a/cmd/node/change_rescue_mode.go +++ b/cmd/node/change_rescue_mode.go @@ -3,6 +3,7 @@ package node import ( "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/cloud/v1/cloudv1grpc" cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "fmt" "github.com/G-PORTAL/gpcore-cli/pkg/client" "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" @@ -21,6 +22,20 @@ var changeRescueModeCmd = &cobra.Command{ Long: "Change rescue mode", RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := client.ExtractContext(cobraCmd) + + // --project-id is optional and falls back to the project selected via + // "project use" (mirrors the generated commands' behavior). + session := ctx.Value("config").(*config.SessionConfig) + if session == nil { + return fmt.Errorf("no session found, please login first") + } + if changeRescueModeProjectId == "" { + if session.CurrentProject == nil { + return fmt.Errorf("no project selected: pass --project-id or select one with \"project use\"") + } + changeRescueModeProjectId = *session.CurrentProject + } + grpcConn := ctx.Value("conn").(*grpc.ClientConn) client := cloudv1grpc.NewCloudServiceClient(grpcConn) resp, err := client.ChangeNodeRescueMode(cobraCmd.Context(), &cloudv1.ChangeNodeRescueModeRequest{ @@ -51,12 +66,11 @@ var changeRescueModeCmd = &cobra.Command{ func init() { changeRescueModeCmd.Flags().StringVar(&changeRescueModeId, "id", "", "Node ID (required)") - changeRescueModeCmd.Flags().StringVar(&changeRescueModeProjectId, "project-id", "", "Project ID (required)") + changeRescueModeCmd.Flags().StringVar(&changeRescueModeProjectId, "project-id", "", "Project ID (defaults to the project selected via \"project use\")") changeRescueModeCmd.Flags().BoolVar(&changeRescueModeEnabled, "enabled", false, "Enable or disable rescue mode") changeRescueModeCmd.Flags().StringVar(&changeRescueModePassword, "password", "", "Password for rescue mode") changeRescueModeCmd.MarkFlagRequired("id") - changeRescueModeCmd.MarkFlagRequired("project-id") RootNodeCommand.AddCommand(changeRescueModeCmd) } diff --git a/cmd/node/root.go b/cmd/node/root.go index fb2786d..ed11ca6 100644 --- a/cmd/node/root.go +++ b/cmd/node/root.go @@ -1,11 +1,13 @@ package node import ( - "fmt" - "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/spf13/cobra" ) +// Note: node subcommands no longer require a globally selected project here. +// Each subcommand accepts an optional --project-id that falls back to the +// project selected via "project use" (and errors if neither is set). This +// avoids forcing "project use" when the project is passed explicitly. var RootNodeCommand = &cobra.Command{ Use: "node", Short: "Utility to combine multiple nodes api actions", @@ -13,17 +15,6 @@ var RootNodeCommand = &cobra.Command{ GroupID: "resources", DisableFlagsInUseLine: true, Args: cobra.MatchAll(cobra.ExactArgs(0), cobra.OnlyValidArgs), - PersistentPreRunE: func(cobraCmd *cobra.Command, args []string) error { - // Context is not set in PersistentPreRunE, so we need to get the session config manually - config, err := config.GetSessionConfig() - if err != nil { - return err - } - if config.CurrentProject == nil { - return fmt.Errorf("no project selected") - } - return nil - }, RunE: func(cobraCmd *cobra.Command, args []string) error { return cobraCmd.Usage() }, diff --git a/cmd/notifications.go b/cmd/notifications.go new file mode 100644 index 0000000..f968717 --- /dev/null +++ b/cmd/notifications.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/cloud/v1/cloudv1grpc" + cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "github.com/G-PORTAL/gpcore-cli/pkg/api" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + "google.golang.org/grpc" +) + +// NotificationsCommand registers the user-facing "notifications" command, which +// streams notifications via cloud.SubscribeNotifications. It shares the +// break/recv stream loop with "livelog" through api.StreamMessages; only the +// per-message rendering differs. +func NotificationsCommand(rootCmd *cobra.Command) { + rootCmd.AddCommand(&cobra.Command{ + Use: "notifications", + Short: "Live notification stream", + Long: "Subscribe to and print live notifications for your account", + DisableFlagsInUseLine: true, + Args: cobra.OnlyValidArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + conn := cobraCmd.Context().Value("conn").(*grpc.ClientConn) + cloudClient := cloudv1grpc.NewCloudServiceClient(conn) + stream, err := cloudClient.SubscribeNotifications(cobraCmd.Context(), &cloudv1.SubscribeNotificationsRequest{}) + if err != nil { + return err + } + + return api.StreamMessages(cobraCmd, stream, func(msg *cloudv1.SubscribeNotificationsResponse) { + notification := msg.GetNotification() + if notification == nil { + return + } + + switch { + case notification.GetNode() != nil: + node := notification.GetNode() + label := text.Colors{text.FgCyan, text.BgBlack}.Sprint("Node") + cobraCmd.Printf("[%s] %s (%s)\n", label, node.GetFqdn(), node.GetId()) + case notification.GetProject() != nil: + project := notification.GetProject() + label := text.Colors{text.FgGreen, text.BgBlack}.Sprint("Project") + cobraCmd.Printf("[%s] %s (%s)\n", label, project.GetName(), project.GetId()) + case notification.GetUser() != nil: + user := notification.GetUser() + label := text.Colors{text.FgMagenta, text.BgBlack}.Sprint("User") + cobraCmd.Printf("[%s] %s (%s)\n", label, user.GetFullName(), user.GetId()) + case notification.GetServerLog() != nil: + m := notification.GetServerLog() + label := text.Colors{text.FgYellow, text.BgBlack}.Sprint("ServerLog") + t := m.GetUpdatedAt().AsTime().Format("15:04:05") + cobraCmd.Printf("[%s] %s -> %s\n", label, t, m.GetMessage()) + case notification.GetHeartbeat() != nil: + // Heartbeats keep the stream alive; ignore them in output. + } + }) + }, + }) +} diff --git a/cmd/project/_network_create.go b/cmd/project/_network_create.go deleted file mode 100644 index 61463b0..0000000 --- a/cmd/project/_network_create.go +++ /dev/null @@ -1,11 +0,0 @@ -package project - -// This command is disabled because the ListSubnets endpoint is missing on -// gRPC (which is needed to resolve subnet IDs). It will be re-enabled once -// the endpoint is available. -// -// TODO: Re-enable once admin.ListSubnets is available in the gRPC API. -// The command should: -// - Accept --subnet-ids and resolve them via ListSubnets -// - Register itself with RootProjectCommand.AddCommand(networkCreateCmd) -// - Fix MarkFlagRequired to use "subnet-ids" (not "subnets") diff --git a/cmd/project/use.go b/cmd/project/use.go index f05d409..4de3851 100644 --- a/cmd/project/use.go +++ b/cmd/project/use.go @@ -3,6 +3,7 @@ package project import ( "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/cloud/v1/cloudv1grpc" cloudv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/cloud/v1" + "fmt" "github.com/G-PORTAL/gpcore-cli/pkg/client" "github.com/G-PORTAL/gpcore-cli/pkg/config" "github.com/charmbracelet/log" @@ -10,18 +11,84 @@ import ( "google.golang.org/grpc" ) +var useClear bool + var useCmd = &cobra.Command{ - Use: "use", - Short: "Selects a project to use", - Long: "Selects a project to use", + Use: "use [project-name-or-id]", + Short: "Select, show or clear the active project", + Long: "Manage the active project used as the default --project-id for\n" + + "project-scoped commands (e.g. \"node get\").\n\n" + + " - With an argument: selects the given project (by name or UUID).\n" + + " - Without arguments: shows the currently active project.\n" + + " - With --clear: clears the active project selection.", + Example: " gpcore project use my-project\n" + + " gpcore project use # show the active project\n" + + " gpcore project use --clear # clear the active project", DisableFlagsInUseLine: true, - Args: cobra.MatchAll(cobra.ExactArgs(1)), + Args: cobra.MaximumNArgs(1), RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := client.ExtractContext(cobraCmd) + cfg := ctx.Value("config").(*config.SessionConfig) + + // Clear the active project. + if useClear { + if len(args) > 0 { + return fmt.Errorf("--clear cannot be combined with a project argument") + } + if cfg.CurrentProject == nil { + cobraCmd.Println("No active project was set.") + return nil + } + cfg.CurrentProject = nil + if err := cfg.Write(); err != nil { + return err + } + if err := config.RefreshSessionConfig(); err != nil { + return err + } + log.Info("Cleared active project") + cobraCmd.Println("Active project cleared.") + return nil + } + grpcConn := ctx.Value("conn").(*grpc.ClientConn) grpcClient := cloudv1grpc.NewCloudServiceClient(grpcConn) - cfg := ctx.Value("config").(*config.SessionConfig) + // No argument: show the currently active project. + if len(args) == 0 { + if cfg.CurrentProject == nil { + cobraCmd.Println("No active project selected.") + cobraCmd.Println("Select one with \"project use \".") + return nil + } + + // Resolve the project to show its name and to verify it is still + // accessible in the current (possibly impersonated) user context. + projectID := *cfg.CurrentProject + resp, err := grpcClient.GetProject(cobraCmd.Context(), &cloudv1.GetProjectRequest{ + Id: projectID, + }) + if err != nil || resp.GetProject() == nil { + // The active project is not accessible in the current context + // (e.g. it was selected while impersonating another user). Clear + // the stale selection so it cannot leak across contexts. + cfg.CurrentProject = nil + if werr := cfg.Write(); werr != nil { + return werr + } + if rerr := config.RefreshSessionConfig(); rerr != nil { + return rerr + } + cobraCmd.Printf("The previously active project (%s) is not accessible "+ + "in the current context and has been cleared.\n", projectID) + cobraCmd.Println("Select one with \"project use \".") + return nil + } + cobraCmd.Printf("Active project: %s (%s)\n", resp.GetProject().GetName(), projectID) + return nil + } + + // Argument given: select the project. var newProject *cloudv1.Project resp, err := grpcClient.ListProjects(cobraCmd.Context(), &cloudv1.ListProjectsRequest{}) @@ -62,5 +129,7 @@ var useCmd = &cobra.Command{ } func init() { + useCmd.Flags().BoolVar(&useClear, "clear", false, "Clear the active project selection") + RootProjectCommand.AddCommand(useCmd) } diff --git a/cmd/server/filter.go b/cmd/server/filter.go new file mode 100644 index 0000000..aabb572 --- /dev/null +++ b/cmd/server/filter.go @@ -0,0 +1,155 @@ +package server + +import ( + "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/admin/v1/adminv1grpc" + adminv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/admin/v1" + typesv1 "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/type/v1" + "fmt" + "strconv" + "strings" + + "github.com/G-PORTAL/gpcore-cli/pkg/api" + "github.com/G-PORTAL/gpcore-cli/pkg/client" + "github.com/G-PORTAL/gpcore-cli/pkg/config" + "github.com/G-PORTAL/gpcore-cli/pkg/protobuf" + "github.com/charmbracelet/ssh" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// filter is implemented manually (not generated) because it calls ListServers +// with an ExtendedSearch payload, which contains a repeated Filter message that +// the YAML generator cannot express as command flags. Use "server +// search-options" to discover the valid filter names and values. + +var filterSearch string +var filterFilters []string + +// parseFilter converts a "name=value" string into a typesv1.Filter. Values are +// treated as strings by default. Prefix the value with "int:" or "bool:" to +// send an integer or boolean filter value respectively. +func parseFilter(raw string) (*typesv1.Filter, error) { + parts := strings.SplitN(raw, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return nil, fmt.Errorf("invalid filter %q, expected format name=value", raw) + } + name, value := parts[0], parts[1] + + builder := typesv1.Filter_builder{Name: name} + switch { + case strings.HasPrefix(value, "int:"): + n, err := strconv.ParseInt(strings.TrimPrefix(value, "int:"), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer filter value for %q: %w", name, err) + } + builder.IntegerValue = &n + case strings.HasPrefix(value, "bool:"): + b, err := strconv.ParseBool(strings.TrimPrefix(value, "bool:")) + if err != nil { + return nil, fmt.Errorf("invalid boolean filter value for %q: %w", name, err) + } + builder.BooleanValue = &b + default: + builder.StringValue = &value + } + return builder.Build(), nil +} + +var filterCmd = &cobra.Command{ + Args: cobra.OnlyValidArgs, + DisableFlagsInUseLine: true, + Long: "List servers using extended search filters. Use 'server search-options' to discover valid filter names and values.", + RunE: func(cobraCmd *cobra.Command, args []string) error { + ctx := client.ExtractContext(cobraCmd) + grpcConn := ctx.Value("conn").(*grpc.ClientConn) + grpcClient := adminv1grpc.NewAdminServiceClient(grpcConn) + + var extendedSearch *typesv1.SearchRequest + if len(filterFilters) > 0 { + filters := make([]*typesv1.Filter, 0, len(filterFilters)) + for _, raw := range filterFilters { + f, err := parseFilter(raw) + if err != nil { + return err + } + filters = append(filters, f) + } + extendedSearch = typesv1.SearchRequest_builder{Filters: filters}.Build() + } + + sshSession := ctx.Value("ssh").(*ssh.Session) + tbl := table.NewWriter() + tbl.SetStyle(table.StyleRounded) + tbl.SetOutputMirror(*sshSession) + cobraCmd.SetOut(*sshSession) + defer cobraCmd.SetOut(nil) + tbl.AppendHeader(table.Row{"Id", "Name", "InPool", "PowerState", "ProvisionState", "CreatedAt"}) + + var combinedData []proto.Message + var totalPages int32 + pagination := &typesv1.PaginationRequest{Page: 1} + for { + req := &adminv1.ListServersRequest{Pagination: pagination} + if filterSearch != "" { + req.Search = &filterSearch + } + if extendedSearch != nil { + req.ExtendedSearch = extendedSearch + } + + resp, err := grpcClient.ListServers(cobraCmd.Context(), req) + if err != nil { + return err + } + + for _, entry := range resp.Servers { + tbl.AppendRow(table.Row{ + fmt.Sprintf("%v", entry.Id), + fmt.Sprintf("%v", entry.Name), + api.FormatBoolean(entry.InPool), + api.FormatServerPowerState(entry.PowerState), + api.FormatServerProvisioningState(entry.ProvisionState), + api.FormatDate(entry.CreatedAt), + }) + combinedData = append(combinedData, entry) + } + + if resp.Pagination == nil { + break + } + totalPages = resp.GetPagination().GetTotal() + pagination.Page++ + if resp.Pagination.Page >= totalPages { + break + } + } + + if config.CSVOutput { + tbl.RenderCSV() + return nil + } + if !config.JSONOutput { + tbl.Render() + } + if config.JSONOutput { + jsonData, err := protobuf.MarshalIndent(combinedData) + if err != nil { + return err + } + cobraCmd.Println(string(jsonData)) + } + return nil + }, + Short: "List servers using extended search filters", + Use: "filter", + ValidArgs: []string{"search", "filter"}, +} + +func init() { + filterCmd.Flags().StringVar(&filterSearch, "search", "", "Optional free-text search term") + filterCmd.Flags().StringArrayVar(&filterFilters, "filter", nil, "Extended search filter in the form name=value (prefix value with int: or bool: to type it). Repeatable.") + + RootServerCommand.AddCommand(filterCmd) +} diff --git a/cmd/user/impersonate.go b/cmd/user/impersonate.go index 4889a1c..5912e92 100644 --- a/cmd/user/impersonate.go +++ b/cmd/user/impersonate.go @@ -46,6 +46,11 @@ var impersonateCmd = &cobra.Command{ expiresIn := int(resp.GetToken().GetExpiresAt().GetSeconds()) sessionConfig.ImpersonateExpiresIn = &expiresIn + // The active project belongs to the previous user context and is not + // valid for the impersonated user. Clear it so project-scoped commands + // don't operate on a project that does not belong to the new context. + sessionConfig.CurrentProject = nil + err = sessionConfig.Write() if err != nil { return err diff --git a/cmd/user/logout.go b/cmd/user/logout.go index 9c65188..f004dbf 100644 --- a/cmd/user/logout.go +++ b/cmd/user/logout.go @@ -28,6 +28,19 @@ var logoutCmd = &cobra.Command{ } if !isImpersonated { + // Even when not impersonating, clear any lingering active project so + // a selection left over from a previous (expired) impersonation does + // not leak into the current user context. + if sessionConfig.CurrentProject != nil { + sessionConfig.CurrentProject = nil + if err = sessionConfig.Write(); err != nil { + return err + } + if err = config.RefreshSessionConfig(); err != nil { + return err + } + cobraCmd.Println("Cleared a lingering active project from a previous session.") + } cobraCmd.Println("No need to logout, you do not impersonate anybody.") return nil } diff --git a/go.mod b/go.mod index 8f7990b..a766836 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ go 1.26.2 //replace github.com/G-PORTAL/gpcore-go => ../gpcore-go require ( - buf.build/gen/go/gportal/gpcore/grpc/go v1.6.1-20260316135506-01d4d7c6b8fb.1 - buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260316135506-01d4d7c6b8fb.1 + buf.build/gen/go/gportal/gpcore/grpc/go v1.6.2-20260601154410-8490479c7661.1 + buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260601154410-8490479c7661.1 github.com/G-PORTAL/gpcore-go v0.0.0-20250923094355-04f2fe445e8f github.com/Nerzal/gocloak/v13 v13.9.0 github.com/charmbracelet/log v1.0.0 diff --git a/go.sum b/go.sum index f2346a5..3c66034 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/gportal/gpcore/grpc/go v1.6.1-20260316135506-01d4d7c6b8fb.1 h1:+242bjl6n/YYlOnNCLgkjo5AaWYWCklo1IszlHa3C/E= -buf.build/gen/go/gportal/gpcore/grpc/go v1.6.1-20260316135506-01d4d7c6b8fb.1/go.mod h1:4mlZn6bCRnl095MLXElGusnT0eRWUtxNi5JVtRqNamM= -buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260316135506-01d4d7c6b8fb.1 h1:OuLb5jA9t2cT9LtVQEij8P2BNaNcl1/jx0IFum+9wXw= -buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260316135506-01d4d7c6b8fb.1/go.mod h1:P8EapWY+m/+4oATK4HFxRi9zB1QyW1oAz+yvxT4ckt4= +buf.build/gen/go/gportal/gpcore/grpc/go v1.6.2-20260601154410-8490479c7661.1 h1:6VSqM8RcjqhIqrk7mJAiHbOfAJANuVkpk7BOzUSCCbU= +buf.build/gen/go/gportal/gpcore/grpc/go v1.6.2-20260601154410-8490479c7661.1/go.mod h1:CtydbfeRdqoQ9NgmVTdXkArcelU4AC3NpBtzToK6A5g= +buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260601154410-8490479c7661.1 h1:Fu0IVtmzobbb6SK5H0s5zrAzmILVXqhmIEja3Sjijwg= +buf.build/gen/go/gportal/gpcore/protocolbuffers/go v1.36.11-20260601154410-8490479c7661.1/go.mod h1:P8EapWY+m/+4oATK4HFxRi9zB1QyW1oAz+yvxT4ckt4= code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= diff --git a/pkg/api/formatter.go b/pkg/api/formatter.go index a442293..d9d24e7 100644 --- a/pkg/api/formatter.go +++ b/pkg/api/formatter.go @@ -37,6 +37,7 @@ var SpecialFormatters = []string{ "ServerProvisioningState", "ServerPowerState", "BillingPeriod", + "ServerIssueSource", } func FormatPrice(price *typesv1.Price) string { @@ -229,3 +230,7 @@ func FormatServerPowerState(state cloudv1.ServerPowerState) string { return strings.TrimPrefix(state.String(), "SERVER_POWER_STATE_") // TODO: More colors here } + +func FormatServerIssueSource(source cloudv1.ServerIssueSource) string { + return strings.TrimPrefix(source.String(), "SERVER_ISSUE_SOURCE_") +} diff --git a/pkg/api/stream.go b/pkg/api/stream.go new file mode 100644 index 0000000..76aee5b --- /dev/null +++ b/pkg/api/stream.go @@ -0,0 +1,46 @@ +package api + +import ( + "github.com/charmbracelet/ssh" + "github.com/spf13/cobra" +) + +// RecvStream is the minimal interface implemented by a gRPC server-streaming +// client (e.g. grpc.ServerStreamingClient[T]). It is satisfied by the streams +// returned by SubscribeServerLogs and SubscribeNotifications. +type RecvStream[T any] interface { + Recv() (*T, error) +} + +// StreamMessages consumes a gRPC server stream until the SSH client sends a +// break (Ctrl-C) or the stream returns an error. Each received message is +// passed to the handler for rendering. This is shared by the "livelog" and +// "notifications" commands, which both follow the same break/recv loop. +func StreamMessages[T any](cobraCmd *cobra.Command, stream RecvStream[T], handler func(msg *T)) error { + sshSession := cobraCmd.Context().Value("ssh").(*ssh.Session) + cobraCmd.SetOut(*sshSession) + + connectionClosed := false + go func() { + breakChan := make(chan bool) + (*sshSession).Break(breakChan) + <-breakChan + connectionClosed = true + }() + + cobraCmd.Printf("\033[33mWaiting for new notifications ...\033[0m\n") + for { + if connectionClosed { + break + } + + msg, err := stream.Recv() // Blocking + if err != nil { + return err + } + + handler(msg) + } + + return nil +} diff --git a/pkg/client/error.go b/pkg/client/error.go new file mode 100644 index 0000000..c36df8a --- /dev/null +++ b/pkg/client/error.go @@ -0,0 +1,60 @@ +package client + +import ( + "fmt" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// FormatCommandError turns an error returned from a command into a clean, +// user-facing message. For gRPC errors it unwraps the underlying status so +// the user sees the actual server message (e.g. "failed to load node") and a +// short, actionable hint based on the status code instead of the raw +// "rpc error: code = Unknown desc = ..." wrapper. +func FormatCommandError(err error) string { + if err == nil { + return "" + } + + st, ok := status.FromError(err) + if !ok { + // Not a gRPC status error, return as-is. + return err.Error() + } + + msg := st.Message() + if hint := hintFor(st.Code(), msg); hint != "" { + return fmt.Sprintf("%s\n%s", msg, hint) + } + return msg +} + +// hintFor returns a short, actionable hint for an error. It inspects the +// message first (to catch cases the status code alone can not distinguish, +// e.g. a missing-role error returned as Unauthenticated) and falls back to a +// per-code hint. An empty string means no extra hint should be shown. +func hintFor(code codes.Code, msg string) string { + lower := strings.ToLower(msg) + + // Missing roles/permissions can be reported by the backend as either + // PermissionDenied or Unauthenticated. Detect it by message so we do not + // wrongly tell the user their session expired. + if strings.Contains(lower, "required role") || strings.Contains(lower, "permission") { + return "Hint: your account is missing the roles required for this action. Ask an administrator to grant the listed roles." + } + + switch code { + case codes.NotFound: + return "Hint: the resource was not found. Verify the IDs you passed and that the selected project (\"project use\" or --project-id) is the one that owns the resource." + case codes.PermissionDenied: + return "Hint: you do not have permission to access this resource." + case codes.Unauthenticated: + return "Hint: authentication failed. Your session may have expired, try logging in again." + case codes.InvalidArgument: + return "Hint: one or more arguments are invalid. Check the values passed to the command." + default: + return "" + } +} diff --git a/pkg/generator/definition.go b/pkg/generator/definition.go index f58d3b4..17ff2bf 100644 --- a/pkg/generator/definition.go +++ b/pkg/generator/definition.go @@ -33,8 +33,36 @@ type Param struct { Required bool `yaml:"required"` Default interface{} `yaml:"default"` Optional bool `yaml:"optional"` // Proto field is a pointer type (oneof/optional) + // Source, when set, makes the flag optional and falls back to a value from + // the session when the flag is left empty. The only supported value is + // "session.CurrentProject", which uses the project selected via + // "project use". If neither the flag nor the session value is set, the + // command errors out. This lets project-scoped commands omit --project-id + // once a project has been selected. + Source string `yaml:"source"` } +// APICall maps a CLI action to a gRPC endpoint via the "api-call" field in the +// YAML definitions (e.g. "admin.ListServers", "cloudv2.ListNodes"). +// +// Intentional API coverage gaps (do NOT add commands for these): +// - payment.* credit-card RPCs (AddCreditCard, RemoveCreditCard, +// ListCreditCards, ChangeDefaultCreditCard): credit cards are no longer +// used, so these calls are not supported. +// - payment.* plan-code RPCs (ListPlanCodes, GetDefaultPlanCode, +// ChangeDefaultPlanCode): Lago is no longer used, so plan codes are not +// supported. +// - auth.* RPCs (CreateClient, ListClients, GetClient, UpdateClient, +// DeleteClient, ResetClientSecret, Register, ResendConfirmEMail, GetUser): +// OAuth client management is not needed in the CLI. +// - admin.GetDashboard: not needed in the CLI. +// - admin.CreateProjectNetwork: dropped. It requires resolving subnet IDs via +// a ListSubnets endpoint that is not available on the gRPC API, and the +// request carries nested struct fields the generator cannot express. The +// previously disabled cmd/project/_network_create.go stub was removed. +// +// Internal / agent-plane services are also intentionally not surfaced in the +// CLI: network.v1.*, metadata.v1.*, gateway.v1.*, and cloud.v2.ReadinessCheck. type APICall struct { Client string Endpoint string diff --git a/pkg/generator/definition/admin-project.yaml b/pkg/generator/definition/admin-project.yaml new file mode 100644 index 0000000..0dbf811 --- /dev/null +++ b/pkg/generator/definition/admin-project.yaml @@ -0,0 +1,60 @@ +description: Administrative project actions (cross-tenant, requires admin credentials) +group: admin + +# NOTE: This resource intentionally exposes the admin.* project endpoints under +# a dedicated "admin-project" command, separate from the user-facing "project" +# command (which is bound to cloud.* endpoints). The two cannot be merged +# because: +# - The gRPC connection is session-global (admin OR client-credentials, see +# pkg/api/connection.go), so we cannot switch credentials per-call. +# - Generated init() functions register subcommands unconditionally, so two +# actions with the same name under one root command would collide in cobra. +# The admin.UpdateProject action is implemented manually in update.go, because +# its request contains a nested BillingProfile message which the generator +# cannot express as command flags. + +actions: + list: + api-call: admin.ListProjects + description: List all projects (admin) + root-key: Projects + params: + - name: search + type: string + description: Optional search term + required: false + - name: user_id + type: string + description: Optional filter by user UUID + required: false + fields: + - Id + - Name + - Currency.Currency + - CanForceDeleteNodes.Boolean + - CreatedAt.Date + + get: + api-call: admin.GetProject + description: Get details for a project (admin) + params: + - name: id + type: string + description: Project UUID + required: true + + nodes: + api-call: admin.ListProjectNodes + description: List all nodes in a project (admin) + root-key: Nodes + params: + - name: id + type: string + description: Project UUID + required: true + fields: + - Id + - Fqdn + - Status.ServerProvisioningState + - Flavour.Flavour + - Datacenter.Datacenter diff --git a/pkg/generator/definition/billing-profile.yaml b/pkg/generator/definition/billing-profile.yaml index 1e6cad7..c182735 100644 --- a/pkg/generator/definition/billing-profile.yaml +++ b/pkg/generator/definition/billing-profile.yaml @@ -102,4 +102,57 @@ actions: - name: billing_email description: Defines the billing email address type: string - default: "" \ No newline at end of file + default: "" + + update: + api-call: payment.UpdateBillingProfile + description: Update an existing billing profile + identifier: nil + params: + - name: id + type: string + description: Billing Profile UUID + required: true + - name: name + type: string + description: Full name + required: true + - name: company + description: Company name + type: string + - name: vat_id + description: Vat ID (e.g. DE123456789) + type: string + - name: country_code + description: Country Code (e.g. DE) + type: string + required: true + - name: state + description: State + type: string + - name: street + description: Street (e.g. Some Road 123) + type: string + required: true + - name: city + description: City + type: string + required: true + - name: postcode + description: Post Code + type: string + required: true + - name: billing_email + description: Defines the billing email address + type: string + default: "" + + delete: + api-call: payment.DeleteBillingProfile + description: Delete a billing profile + identifier: nil + params: + - name: id + type: string + description: Billing Profile UUID + required: true \ No newline at end of file diff --git a/pkg/generator/definition/flavour.yaml b/pkg/generator/definition/flavour.yaml index de3eb0a..5960432 100644 --- a/pkg/generator/definition/flavour.yaml +++ b/pkg/generator/definition/flavour.yaml @@ -31,6 +31,11 @@ actions: type: string description: Datacenter UUID required: true + - name: include_sold_out + type: bool + description: Include sold-out flavours in the result + required: false + optional: true fields: - Id - Name @@ -106,6 +111,9 @@ actions: update: api-call: admin.UpdateFlavour description: Update flavour details + # NOTE: UpdateFlavourRequest also has a repeated "mappings" + # ([]FlavourMapping) field. It is intentionally not exposed because the + # generator cannot express a repeated nested message as command flags. params: - name: id type: string diff --git a/pkg/generator/definition/image.yaml b/pkg/generator/definition/image.yaml index 1fa8ad3..c999cc3 100644 --- a/pkg/generator/definition/image.yaml +++ b/pkg/generator/definition/image.yaml @@ -77,6 +77,10 @@ actions: type: "[]cloudv1.AuthenticationType" description: Authentication types required: true + - name: contains_spla_license + type: bool + description: Contains SPLA license + required: true create: api-call: admin.CreateImage diff --git a/pkg/generator/definition/ip.yaml b/pkg/generator/definition/ip.yaml index dc2869e..423936a 100644 --- a/pkg/generator/definition/ip.yaml +++ b/pkg/generator/definition/ip.yaml @@ -6,6 +6,11 @@ actions: api-call: admin.ListIPHistories description: List all IP history entries root-key: IpHistories + params: + - name: search + type: string + description: Optional search term + required: false fields: - CreatedAt.DateTime - User diff --git a/pkg/generator/definition/log.yaml b/pkg/generator/definition/log.yaml index cb02ee0..4879dbd 100644 --- a/pkg/generator/definition/log.yaml +++ b/pkg/generator/definition/log.yaml @@ -24,6 +24,19 @@ actions: api-call: admin.ListAdminLogs description: List all admin logs root-key: Logs + params: + - name: search + type: string + description: Optional search term + required: false + - name: admin_user_id + type: string + description: Filter by admin user UUID + required: false + - name: user_id + type: string + description: Filter by target user UUID + required: false fields: - CreatedAt.DateTime - AdminUser.BasicUser diff --git a/pkg/generator/definition/network-arp.yaml b/pkg/generator/definition/network-arp.yaml index 78e84be..9d35055 100644 --- a/pkg/generator/definition/network-arp.yaml +++ b/pkg/generator/definition/network-arp.yaml @@ -21,4 +21,10 @@ actions: - IpAddress - MacAddress - UpdatedAt.Date - # TODO: Implement SearchRequest/SearchOptions to include extended_search here \ No newline at end of file + # TODO: Implement SearchRequest/SearchOptions to include extended_search here + + # search-options returns the valid extended-search filter fields for ARP + # entries (the counterpart to "server search-options"). + search-options: + api-call: admin.GetArpEntriesSearchOptions + description: List available extended-search filter options for ARP entries \ No newline at end of file diff --git a/pkg/generator/definition/network-switch.yaml b/pkg/generator/definition/network-switch.yaml index 450a48b..ce1c636 100644 --- a/pkg/generator/definition/network-switch.yaml +++ b/pkg/generator/definition/network-switch.yaml @@ -6,6 +6,11 @@ actions: api-call: admin.ListSwitches description: List all network switches root-key: Switches + params: + - name: search + type: string + description: Optional search term + required: false fields: - Id - Name diff --git a/pkg/generator/definition/node.yaml b/pkg/generator/definition/node.yaml index 16c8420..69a5b46 100644 --- a/pkg/generator/definition/node.yaml +++ b/pkg/generator/definition/node.yaml @@ -6,6 +6,11 @@ actions: api-call: cloudv2.ListNodes description: List all nodes in the project root-key: Nodes + params: + - name: search + type: string + description: Optional search term + required: false fields: - Id - Fqdn @@ -31,7 +36,7 @@ actions: - name: project_id description: Project ID type: string - required: true + source: session.CurrentProject # There is an update actions in the cloud API as well. Not sure which # update node actions we should use here. @@ -56,7 +61,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject - name: flavour_id type: string description: Flavour ID @@ -102,7 +107,7 @@ actions: - name: project_id type: string description: Project UUID - required: true + source: session.CurrentProject destroy-immediately: api-call: admin.DestroyNode @@ -126,7 +131,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject - name: fqdn type: string description: Fully qualified domain name @@ -160,7 +165,7 @@ actions: - name: project_id description: Project ID type: string - required: true + source: session.CurrentProject - name: power_state type: cloudv1.ServerPowerState default: cloudv1.SERVER_POWER_STATE_REBOOT @@ -189,7 +194,8 @@ actions: required: true - name: project_id type: string - required: true + description: Project ID + source: session.CurrentProject - name: billing_period type: cloudv1.BillingPeriod default: cloudv1.BILLING_PERIOD_MONTHLY diff --git a/pkg/generator/definition/operating-systems.yaml b/pkg/generator/definition/operating-systems.yaml index 6e9c867..3e11456 100644 --- a/pkg/generator/definition/operating-systems.yaml +++ b/pkg/generator/definition/operating-systems.yaml @@ -37,6 +37,9 @@ actions: update: api-call: admin.UpdateOperatingSystem description: Update operating system details + # NOTE: UpdateOperatingSystemRequest also has an optional "icon" (File) + # field. It is intentionally not exposed because the generator cannot + # express a nested File message (file upload) as command flags. params: - name: id type: string diff --git a/pkg/generator/definition/project-cloudprovider.yaml b/pkg/generator/definition/project-cloudprovider.yaml index ef97d2b..51bd5b0 100644 --- a/pkg/generator/definition/project-cloudprovider.yaml +++ b/pkg/generator/definition/project-cloudprovider.yaml @@ -137,6 +137,10 @@ actions: identifier: nil params: - name: image_id + type: string + description: Image UUID + required: true + - name: cloud_provider_image_id type: string description: Cloud Provider image ID required: true diff --git a/pkg/generator/definition/project-image.yaml b/pkg/generator/definition/project-image.yaml index fc15e6c..3357490 100644 --- a/pkg/generator/definition/project-image.yaml +++ b/pkg/generator/definition/project-image.yaml @@ -24,7 +24,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject create: api-call: cloud.CreateProjectImage @@ -55,7 +55,7 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject delete-version: api-call: cloud.DeleteProjectImageVersion @@ -69,6 +69,6 @@ actions: - name: project_id type: string description: Project ID - required: true + source: session.CurrentProject identifier: session.CurrentProject \ No newline at end of file diff --git a/pkg/generator/definition/project.yaml b/pkg/generator/definition/project.yaml index 09c7db2..d02c392 100644 --- a/pkg/generator/definition/project.yaml +++ b/pkg/generator/definition/project.yaml @@ -88,6 +88,11 @@ actions: description: Datacenter to fetch the flavours from type: string required: true + - name: include_sold_out + type: bool + description: Include sold-out flavours in the result + required: false + optional: true fields: - Id - Name @@ -107,6 +112,15 @@ actions: logs: api-call: cloudv2.ListProjectLogs description: List all logs in the project + params: + - name: search + type: string + description: Optional search term + default: "" + - name: user_id + type: string + description: Filter by user UUID + default: "" fields: - CreatedAt.DateTime - User.BasicUser @@ -173,6 +187,12 @@ actions: images: api-call: cloud.ListProjectImages description: List all images in the project + params: + - name: only_available + type: bool + description: Only include available images + required: false + optional: true # TODO: Custom formatter to output keys in authorized_keys format ssh-keys: diff --git a/pkg/generator/definition/server.yaml b/pkg/generator/definition/server.yaml index d20ac80..5bf7293 100644 --- a/pkg/generator/definition/server.yaml +++ b/pkg/generator/definition/server.yaml @@ -19,6 +19,14 @@ actions: description: Optional search term required: false + # search-options returns the set of valid extended-search filter fields and + # their allowed values. Use it to discover the --filter keys accepted by the + # "server filter" command (which calls the same ListServers RPC with an + # ExtendedSearch payload). + search-options: + api-call: admin.GetServerSearchOptions + description: List available extended-search filter options for servers + list-available: api-call: admin.ListAvailableServer description: List all available servers @@ -77,6 +85,19 @@ actions: type: string description: Server Pool UUID required: false + # is_broken is deprecated in the gRPC API and intentionally not exposed. + - name: autofix + type: bool + description: Enable the automated monitoring fix job for this server + default: false + - name: maintenance + type: bool + description: Set or clear maintenance mode + required: false + - name: maintenance_reason + type: string + description: Reason for maintenance (used when maintenance is true) + required: false delete: api-call: admin.DeleteServer @@ -91,6 +112,10 @@ actions: description: Force destroy the server, even if the server is unresponsive default: false required: true + - name: skip_clean + type: bool + description: Allow deletion without cleaning the device + default: false replace: api-call: admin.ReplaceServer @@ -115,6 +140,55 @@ actions: description: Get server platform management details params: - name: id + type: string + description: Server UUID + required: true + + issues: + api-call: admin.ListServerIssues + description: List server issues across all servers + root-key: Issues + params: + - name: source + type: cloudv1.ServerIssueSource + description: Filter by issue source + optional: true + required: false + - name: datacenter_id + type: string + description: Filter by datacenter UUID + required: false + - name: include_resolved + type: bool + description: Include resolved issues + required: false + optional: true + - name: check_name + type: string + description: Filter by check name (e.g. device_exists, primary_interface, pm_offline) + required: false + fields: + - Id + - ServerId + - Source.ServerIssueSource + - CheckName + - Message + - Resolved.Boolean + + server-issues: + api-call: admin.GetServerIssues + description: Get all issues for a specific server + params: + - name: server_id + type: string + description: Server UUID + required: true + + validate: + api-call: admin.ValidateServer + description: Validate a server and trigger a sync if needed + params: + - name: server_id type: string description: Server UUID required: true \ No newline at end of file diff --git a/pkg/generator/definition/spla.yaml b/pkg/generator/definition/spla.yaml index b7c8e7c..a11d6fc 100644 --- a/pkg/generator/definition/spla.yaml +++ b/pkg/generator/definition/spla.yaml @@ -4,4 +4,13 @@ group: billing actions: get: api-call: admin.GetSplaReporting - description: Get SPLA reporting details \ No newline at end of file + description: Get SPLA reporting details + params: + - name: year + type: int32 + description: Reporting year + required: true + - name: month + type: int32 + description: Reporting month (1-12) + required: true \ No newline at end of file diff --git a/pkg/generator/definition/user.yaml b/pkg/generator/definition/user.yaml index aea5311..2422fd2 100644 --- a/pkg/generator/definition/user.yaml +++ b/pkg/generator/definition/user.yaml @@ -80,4 +80,12 @@ actions: - name: user_id type: string description: User ID - required: true \ No newline at end of file + required: true + - name: reason + type: string + description: Reason for unlocking the user + required: true + + stats: + api-call: admin.GetUserStats + description: Get aggregated user statistics \ No newline at end of file diff --git a/pkg/generator/helpers.go b/pkg/generator/helpers.go index eae2bb3..30eacd8 100644 --- a/pkg/generator/helpers.go +++ b/pkg/generator/helpers.go @@ -216,6 +216,11 @@ func defaultValue(param Param) *Statement { if isArrayType(param.Type) { return Index().Id(strings.TrimPrefix(param.Type, "[]")).Values(Dict{}) } + // Enum-typed flags are bound to string variables, so an empty + // string is the correct zero value when no default is given. + if isEnumType(param.Type) { + return Lit("") + } } } else { // TODO: Fileupload @@ -252,6 +257,11 @@ func defaultValue(param Param) *Statement { // parameterDescription returns the description of a parameter. If the parameter // has a default value, the default value is added to the description. func parameterDescription(param Param) string { + // Session-sourced params are optional flags with a session fallback. + if param.Source == "session.CurrentProject" { + return fmt.Sprintf("%s (defaults to the project selected via \"project use\")", param.Description) + } + var flags []string if param.Required { flags = append(flags, "required") diff --git a/pkg/generator/sub_command.go b/pkg/generator/sub_command.go index d93bc20..7a1fb46 100644 --- a/pkg/generator/sub_command.go +++ b/pkg/generator/sub_command.go @@ -44,6 +44,11 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro for _, param := range metadata.Action.Params { f.Var().Add(variableDefinition(name, param)) } + // When the identifier is the current-project session value, declare the + // optional --project-id override variable. + if resolveIdentifier(metadata) == "session.CurrentProject" { + f.Var().Id(strcase.LowerCamelCase(name) + "ProjectIdOverride").String() + } f.Line() // Enum helper functions @@ -121,21 +126,7 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { // Identifiers can be set on the action or the definition. If the action // is set, the action identifier is used. If not, the definition identifier // is used. - identifier := "" - // "Global identifier for all actions present? - if metadata.Definition.Identifier != "" { - identifier = metadata.Definition.Identifier - } - // Override with action identifier - if metadata.Action.Identifier != "" { - // Reset identifier on purpose (override) global identifier - if metadata.Action.Identifier == "nil" { - identifier = "" - } else { - // Action specific identifier - identifier = metadata.Action.Identifier - } - } + identifier := resolveIdentifier(metadata) // The identifier key is the key used in the request to identify the // resource. By default, this is "Id". This can be overridden globally @@ -148,6 +139,13 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { identifierKey = metadata.Action.IdentifierKey } + // When the identifier comes from the current project session value, we also + // expose an optional --project-id flag that overrides it. This lets the + // command be used either after "project use" or by passing --project-id + // explicitly (mirroring the source: session.CurrentProject params). + identifierProjectOverride := identifier == "session.CurrentProject" + identifierOverrideVar := strcase.LowerCamelCase(name) + "ProjectIdOverride" + if identifier != "" { c = append(c, Id("session").Op(":="). Id("ctx"). @@ -155,8 +153,56 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { Assert(Op("*").Qual("github.com/G-PORTAL/gpcore-cli/pkg/config", "SessionConfig"))) c = append(c, If(Id("session").Op("==").Nil()).Block( Return(Qual("fmt", "Errorf").Call(Lit("no session found, please login first"))))) - c = append(c, If(Id(identifier).Op("==").Nil()).Block( - Return(Qual("fmt", "Errorf").Call(Lit("no identifier found, please set the identifier first"))))) + + if identifierProjectOverride { + // resolvedProjectId := + // if resolvedProjectId == "" { if session.CurrentProject == nil { err } else { resolvedProjectId = *session.CurrentProject } } + c = append(c, Id("resolvedProjectId").Op(":=").Id(identifierOverrideVar)) + c = append(c, If(Id("resolvedProjectId").Op("==").Lit("")).Block( + If(Id(identifier).Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call( + Lit("no project selected: pass --project-id or select one with \"project use\"")))), + Id("resolvedProjectId").Op("=").Op("*").Id(identifier), + )) + } else { + c = append(c, If(Id(identifier).Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call(Lit("no identifier found, please set the identifier first"))))) + } + c = append(c, Line()) + } + + // Session-sourced params (e.g. --project-id falling back to the project + // selected via "project use"). The flag is optional; when empty we fall + // back to the session value and error if that is unset as well. + sourceParams := make([]Param, 0) + for _, param := range metadata.Action.Params { + if param.Source != "" { + sourceParams = append(sourceParams, param) + } + } + if len(sourceParams) > 0 { + // Declare the session once (it may already be declared above when an + // identifier is used). + if identifier == "" { + c = append(c, Id("session").Op(":="). + Id("ctx"). + Dot("Value").Call(Lit("config")). + Assert(Op("*").Qual("github.com/G-PORTAL/gpcore-cli/pkg/config", "SessionConfig"))) + c = append(c, If(Id("session").Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call(Lit("no session found, please login first"))))) + } + for _, param := range sourceParams { + variable := strcase.LowerCamelCase(name) + title(strcase.LowerCamelCase(param.Name)) + sessionField := strings.TrimPrefix(param.Source, "session.") + flagName := strcase.KebabCase(param.Name) + // if == "" { if session. == nil { error } else { var = *session. } } + c = append(c, If(Id(variable).Op("==").Lit("")).Block( + If(Id("session").Dot(sessionField).Op("==").Nil()).Block( + Return(Qual("fmt", "Errorf").Call( + Lit("no project selected: pass --"+flagName+" or select one with \"project use\"")))), + Id(variable).Op("=").Op("*").Id("session").Dot(sessionField), + )) + } c = append(c, Line()) } @@ -177,7 +223,11 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { apiCallParams := Dict{} // Do we have an identifier? if identifier != "" { - apiCallParams[Id(identifierKey)] = Op("*").Id(identifier) + if identifierProjectOverride { + apiCallParams[Id(identifierKey)] = Id("resolvedProjectId") + } else { + apiCallParams[Id(identifierKey)] = Op("*").Id(identifier) + } } // Specific parameters set? @@ -194,6 +244,10 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { } else { val = Qual("github.com/G-PORTAL/gpcore-cli/pkg/protobuf", stripPackage(param.Type)+"ToProto").Call(Id(variable)) } + } else if param.Source != "" { + // Session-sourced param: the variable is resolved (flag or session + // fallback) before the API call and always passed by value. + val = Id(variable) } else { // Optional pointer type if !param.Required && param.Default == nil { @@ -556,6 +610,24 @@ func tableColValue(col string, structPrefix string) []Code { return c } +// resolveIdentifier returns the effective identifier for an action, taking the +// definition-level identifier and any action-level override (including the +// "nil" reset) into account. An empty string means no identifier is used. +func resolveIdentifier(metadata SubcommandMetadata) string { + identifier := "" + if metadata.Definition.Identifier != "" { + identifier = metadata.Definition.Identifier + } + if metadata.Action.Identifier != "" { + if metadata.Action.Identifier == "nil" { + identifier = "" + } else { + identifier = metadata.Action.Identifier + } + } + return identifier +} + // initFunc generates the init function, which will add all flags and params to // the command and add it to the root command. Required flags are marked as // such. @@ -595,10 +667,28 @@ func initFunc(name string, metadata SubcommandMetadata) []Code { c = append(c, Line()) } - // Required fields + // Optional --project-id override for commands whose identifier is the + // current-project session value. Lets the command run either after + // "project use" or with an explicit --project-id. + if resolveIdentifier(metadata) == "session.CurrentProject" { + c = append(c, + Id(name+"Cmd"). + Dot("Flags").Call(). + Dot("StringVar").Params( + Op("&").Id(strcase.LowerCamelCase(name)+"ProjectIdOverride"), + Lit("project-id"), + Lit(""), + Lit("Project UUID (defaults to the project selected via \"project use\")"), + )) + c = append(c, Line()) + } + + // Required fields. Session-sourced params (param.Source set) are never + // marked required: they fall back to the session value (e.g. the project + // selected via "project use") when the flag is omitted. containsRequiredFields := false for _, param := range metadata.Action.Params { - if param.Required { + if param.Required && param.Source == "" { c = append(c, Id(strcase.LowerCamelCase(name)+"Cmd"). Dot("MarkFlagRequired").