diff --git a/cmd/aiservices/aiservices.go b/cmd/aiservices/aiservices.go index f0779972..44a864d8 100644 --- a/cmd/aiservices/aiservices.go +++ b/cmd/aiservices/aiservices.go @@ -2,6 +2,7 @@ package aiservices import ( exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/cmd/aiservices/apikey" "github.com/exoscale/cli/cmd/aiservices/deployment" "github.com/exoscale/cli/cmd/aiservices/model" "github.com/spf13/cobra" @@ -18,4 +19,5 @@ func init() { // Attach subcommand groups DedicatedInferenceCmd.AddCommand(model.Cmd) DedicatedInferenceCmd.AddCommand(deployment.Cmd) + DedicatedInferenceCmd.AddCommand(apikey.Cmd) } diff --git a/cmd/aiservices/apikey/apikey.go b/cmd/aiservices/apikey/apikey.go new file mode 100644 index 00000000..f8875719 --- /dev/null +++ b/cmd/aiservices/apikey/apikey.go @@ -0,0 +1,11 @@ +package apikey + +import ( + "github.com/spf13/cobra" +) + +// Cmd is the root command for apikey subcommands. +var Cmd = &cobra.Command{ + Use: "api-key", + Short: "Manage AI API keys", +} diff --git a/cmd/aiservices/apikey/apikey_create.go b/cmd/aiservices/apikey/apikey_create.go new file mode 100644 index 00000000..bf19a0f3 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_create.go @@ -0,0 +1,62 @@ +package apikey + +import ( + "fmt" + "os" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyCreateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"create"` + + Name string `cli-flag:"name" cli-usage:"AI API key name"` + Scope string `cli-flag:"scope" cli-usage:"Scope: 'public' for all deployments, or a deployment UUID"` +} + +func (c *AIAPIKeyCreateCmd) CmdAliases() []string { return exocmd.GCreateAlias } +func (c *AIAPIKeyCreateCmd) CmdShort() string { return "Create AI API key" } +func (c *AIAPIKeyCreateCmd) CmdLong() string { + return "This command creates an AI API key." +} +func (c *AIAPIKeyCreateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyCreateCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + if c.Name == "" { + return fmt.Errorf("--name is required") + } + if c.Scope == "" { + return fmt.Errorf("--scope is required") + } + + req := v3.CreateAIAPIKeyRequest{ + Name: c.Name, + Scope: c.Scope, + } + + resp, err := client.CreateAIAPIKey(ctx, req) + if err != nil { + return err + } + + if !globalstate.Quiet { + fmt.Fprintf(os.Stderr, "Use 'reveal' command to get the API key value.\n\n") + fmt.Fprintf(os.Stdout, "ID: %s\n", resp.ID) + fmt.Fprintf(os.Stdout, "Name: %s\n", resp.Name) + fmt.Fprintf(os.Stdout, "Scope: %s\n", resp.Scope) + } + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyCreateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_create_test.go b/cmd/aiservices/apikey/apikey_create_test.go new file mode 100644 index 00000000..65a8cf66 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_create_test.go @@ -0,0 +1,38 @@ +package apikey + +import ( + "context" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyCreateValidationAndSuccess(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + // missing flags + c := &AIAPIKeyCreateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := c.CmdRun(nil, nil); err == nil { + t.Fatalf("expected error for missing flags") + } + + // success + c.Name = "test-key" + c.Scope = "public" + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("create api key: %v", err) + } +} diff --git a/cmd/aiservices/apikey/apikey_delete.go b/cmd/aiservices/apikey/apikey_delete.go new file mode 100644 index 00000000..24006101 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_delete.go @@ -0,0 +1,72 @@ +package apikey + +import ( + "fmt" + "os" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyDeleteCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"delete"` + + IDs []string `cli-arg:"#" cli-usage:"AI API key ID..."` + Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"` +} + +func (c *AIAPIKeyDeleteCmd) CmdAliases() []string { return exocmd.GDeleteAlias } +func (c *AIAPIKeyDeleteCmd) CmdShort() string { return "Delete AI API key" } +func (c *AIAPIKeyDeleteCmd) CmdLong() string { + return "This command deletes AI API keys by ID." +} +func (c *AIAPIKeyDeleteCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyDeleteCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + if len(c.IDs) == 0 { + return fmt.Errorf("at least one ID is required") + } + + for _, idStr := range c.IDs { + id, err := v3.ParseUUID(idStr) + if err != nil { + if !c.Force { + return fmt.Errorf("invalid AI API key ID %q: %w", idStr, err) + } + fmt.Fprintf(os.Stderr, "warning: invalid AI API key ID %q\n", idStr) + continue + } + + if !c.Force { + if !utils.AskQuestion(ctx, fmt.Sprintf("Are you sure you want to delete AI API key %q?", idStr)) { + continue + } + } + + if _, err := client.DeleteAIAPIKey(ctx, id); err != nil { + if !c.Force { + return err + } + fmt.Fprintf(os.Stderr, "warning: failed to delete AI API key %q: %v\n", idStr, err) + continue + } + } + + if !globalstate.Quiet { + fmt.Fprintln(os.Stdout, "AI API key(s) deleted.") + } + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_delete_test.go b/cmd/aiservices/apikey/apikey_delete_test.go new file mode 100644 index 00000000..a00b97a5 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_delete_test.go @@ -0,0 +1,38 @@ +package apikey + +import ( + "context" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyDeleteValidationAndSuccess(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + // missing IDs + c := &AIAPIKeyDeleteCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := c.CmdRun(nil, nil); err == nil { + t.Fatalf("expected error for missing IDs") + } + + // success with force + c.IDs = []string{"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} + c.Force = true + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("delete api key: %v", err) + } +} diff --git a/cmd/aiservices/apikey/apikey_get.go b/cmd/aiservices/apikey/apikey_get.go new file mode 100644 index 00000000..0884a4d0 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_get.go @@ -0,0 +1,73 @@ +package apikey + +import ( + "fmt" + "time" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyShowOutput struct { + ID v3.UUID `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + CreatedAt string `json:"created_at" outputLabel:"Created At"` + UpdatedAt string `json:"updated_at" outputLabel:"Updated At"` +} + +func (o *AIAPIKeyShowOutput) ToJSON() { output.JSON(o) } +func (o *AIAPIKeyShowOutput) ToText() { output.Text(o) } +func (o *AIAPIKeyShowOutput) ToTable() { output.Table(o) } + +type AIAPIKeyGetCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"show"` + + ID string `cli-arg:"#" cli-usage:"AI API key ID"` +} + +func (c *AIAPIKeyGetCmd) CmdAliases() []string { return exocmd.GShowAlias } +func (c *AIAPIKeyGetCmd) CmdShort() string { return "Show AI API key details" } +func (c *AIAPIKeyGetCmd) CmdLong() string { + return "This command shows details of an AI API key by ID." +} +func (c *AIAPIKeyGetCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyGetCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + if c.ID == "" { + return fmt.Errorf("ID is required") + } + + id, err := v3.ParseUUID(c.ID) + if err != nil { + return fmt.Errorf("invalid AI API key ID: %w", err) + } + + resp, err := client.GetAIAPIKey(ctx, id) + if err != nil { + return err + } + + out := &AIAPIKeyShowOutput{ + ID: resp.ID, + Name: resp.Name, + Scope: resp.Scope, + CreatedAt: resp.CreatedAT.Format(time.RFC3339), + UpdatedAt: resp.UpdatedAT.Format(time.RFC3339), + } + + return c.OutputFunc(out, nil) +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyGetCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_get_test.go b/cmd/aiservices/apikey/apikey_get_test.go new file mode 100644 index 00000000..10407bd0 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_get_test.go @@ -0,0 +1,54 @@ +package apikey + +import ( + "context" + "testing" + "time" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyGet(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + now := time.Now() + ts.keys = []v3.AIAPIKey{ + {ID: v3.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Name: "key1", Scope: "public", CreatedAT: now, UpdatedAT: now}, + } + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + c := &AIAPIKeyGetCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("get api key: %v", err) + } +} + +func TestAIAPIKeyGetMissingID(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + c := &AIAPIKeyGetCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := c.CmdRun(nil, nil); err == nil { + t.Fatalf("expected error for missing ID") + } +} diff --git a/cmd/aiservices/apikey/apikey_helpers_test.go b/cmd/aiservices/apikey/apikey_helpers_test.go new file mode 100644 index 00000000..1e420904 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_helpers_test.go @@ -0,0 +1,126 @@ +package apikey + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +type apikeyHelperServer struct { + server *httptest.Server + keys []v3.AIAPIKey +} + +func newAPIKeyHelperServer(t *testing.T) *apikeyHelperServer { + ts := &apikeyHelperServer{} + mux := http.NewServeMux() + mux.HandleFunc("/ai/api-key", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(t, w, http.StatusOK, v3.ListAIAPIKeysResponse{AIAPIKeys: ts.keys}) + case http.MethodPost: + writeJSON(t, w, http.StatusOK, v3.AIAPIKey{ + ID: v3.UUID("new-key-id"), + Name: "new-key", + Scope: "public", + CreatedAT: time.Now(), + UpdatedAT: time.Now(), + }) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/ai/api-key/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path[len("/ai/api-key/"):] + if r.Method == http.MethodPost && strings.HasSuffix(path, "/rotate") { + id := strings.TrimSuffix(path, "/rotate") + writeJSON(t, w, http.StatusOK, v3.AIAPIKeyValue{ + Value: "exo_ai_rotated_value", + }) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(path, "/reveal") { + id := strings.TrimSuffix(path, "/reveal") + writeJSON(t, w, http.StatusOK, v3.AIAPIKeyValue{ + Value: "exo_ai_revealed_value", + }) + return + } + id := path + switch r.Method { + case http.MethodGet: + for _, k := range ts.keys { + if string(k.ID) == id { + writeJSON(t, w, http.StatusOK, k) + return + } + } + w.WriteHeader(http.StatusNotFound) + case http.MethodPatch: + writeJSON(t, w, http.StatusOK, v3.AIAPIKey{ + ID: v3.UUID(id), + Name: "updated-key", + Scope: "public", + CreatedAT: time.Now(), + UpdatedAT: time.Now(), + }) + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + ts.server = httptest.NewServer(mux) + return ts +} + +func writeJSON(t *testing.T, w http.ResponseWriter, code int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("encode json: %v", err) + } +} + +func TestFindAIAPIKeyByIDAndName(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + now := time.Now() + ts.keys = []v3.AIAPIKey{ + {ID: v3.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Name: "alpha", Scope: "public", CreatedAT: now, UpdatedAT: now}, + } + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + ctx := context.Background() + + // by ID + list, err := client.ListAIAPIKeys(ctx) + if err != nil { + t.Fatalf("list api keys: %v", err) + } + entry, err := list.FindAIAPIKey("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + if err != nil || string(entry.ID) != "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" { + t.Fatalf("resolve by id failed: %v %v", entry.ID, err) + } + // by name + entry, err = list.FindAIAPIKey("alpha") + if err != nil || string(entry.ID) != "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" { + t.Fatalf("resolve by name failed: %v %v", entry.ID, err) + } + // not found + _, err = list.FindAIAPIKey("missing") + if err == nil { + t.Fatalf("expected not found error, got %v", err) + } +} diff --git a/cmd/aiservices/apikey/apikey_list.go b/cmd/aiservices/apikey/apikey_list.go new file mode 100644 index 00000000..ad4a8736 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_list.go @@ -0,0 +1,69 @@ +package apikey + +import ( + "fmt" + "strings" + "time" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyListItemOutput struct { + ID v3.UUID `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + CreatedAt string `json:"created_at" outputLabel:"Created At"` +} + +type AIAPIKeyListOutput []AIAPIKeyListItemOutput + +func (o *AIAPIKeyListOutput) ToJSON() { output.JSON(o) } +func (o *AIAPIKeyListOutput) ToText() { output.Text(o) } +func (o *AIAPIKeyListOutput) ToTable() { output.Table(o) } + +type AIAPIKeyListCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"list"` +} + +func (c *AIAPIKeyListCmd) CmdAliases() []string { return exocmd.GListAlias } +func (c *AIAPIKeyListCmd) CmdShort() string { return "List AI API keys" } +func (c *AIAPIKeyListCmd) CmdLong() string { + return fmt.Sprintf(`This command lists AI API keys. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&AIAPIKeyListOutput{}), ", ")) +} +func (c *AIAPIKeyListCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyListCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + resp, err := client.ListAIAPIKeys(ctx) + if err != nil { + return err + } + + out := make(AIAPIKeyListOutput, 0, len(resp.AIAPIKeys)) + for _, key := range resp.AIAPIKeys { + out = append(out, AIAPIKeyListItemOutput{ + ID: key.ID, + Name: key.Name, + Scope: key.Scope, + CreatedAt: key.CreatedAT.Format(time.RFC3339), + }) + } + + return c.OutputFunc(&out, nil) +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyListCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_list_test.go b/cmd/aiservices/apikey/apikey_list_test.go new file mode 100644 index 00000000..2c2b2517 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_list_test.go @@ -0,0 +1,36 @@ +package apikey + +import ( + "context" + "testing" + "time" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyList(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + now := time.Now() + ts.keys = []v3.AIAPIKey{ + {ID: v3.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Name: "key1", Scope: "public", CreatedAT: now, UpdatedAT: now}, + {ID: v3.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Name: "key2", Scope: "public", CreatedAT: now, UpdatedAT: now}, + } + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + cmd := &AIAPIKeyListCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := cmd.CmdRun(nil, nil); err != nil { + t.Fatalf("list api keys: %v", err) + } +} diff --git a/cmd/aiservices/apikey/apikey_reveal.go b/cmd/aiservices/apikey/apikey_reveal.go new file mode 100644 index 00000000..2520a72f --- /dev/null +++ b/cmd/aiservices/apikey/apikey_reveal.go @@ -0,0 +1,56 @@ +package apikey + +import ( + "fmt" + "os" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyRevealCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"reveal"` + + ID string `cli-arg:"#" cli-usage:"AI API key ID"` +} + +func (c *AIAPIKeyRevealCmd) CmdAliases() []string { return []string{} } +func (c *AIAPIKeyRevealCmd) CmdShort() string { return "Reveal AI API key value" } +func (c *AIAPIKeyRevealCmd) CmdLong() string { + return "This command reveals the secret value of an AI API key by ID." +} +func (c *AIAPIKeyRevealCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyRevealCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + if c.ID == "" { + return fmt.Errorf("ID is required") + } + + id, err := v3.ParseUUID(c.ID) + if err != nil { + return fmt.Errorf("invalid AI API key ID: %w", err) + } + + resp, err := client.RevealAIAPIKey(ctx, id) + if err != nil { + return err + } + + if !globalstate.Quiet { + fmt.Fprintf(os.Stderr, "Store this API key value securely.\n\n") + fmt.Fprintf(os.Stdout, "Value: %s\n", resp.Value) + } + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyRevealCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_reveal_test.go b/cmd/aiservices/apikey/apikey_reveal_test.go new file mode 100644 index 00000000..96ca4055 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_reveal_test.go @@ -0,0 +1,37 @@ +package apikey + +import ( + "context" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyRevealValidationAndSuccess(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + // missing ID + c := &AIAPIKeyRevealCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := c.CmdRun(nil, nil); err == nil { + t.Fatalf("expected error for missing ID") + } + + // success + c.ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("reveal api key: %v", err) + } +} diff --git a/cmd/aiservices/apikey/apikey_rotate.go b/cmd/aiservices/apikey/apikey_rotate.go new file mode 100644 index 00000000..7fe134f5 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_rotate.go @@ -0,0 +1,56 @@ +package apikey + +import ( + "fmt" + "os" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyRotateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"rotate"` + + ID string `cli-arg:"#" cli-usage:"AI API key ID"` +} + +func (c *AIAPIKeyRotateCmd) CmdAliases() []string { return []string{} } +func (c *AIAPIKeyRotateCmd) CmdShort() string { return "Rotate AI API key" } +func (c *AIAPIKeyRotateCmd) CmdLong() string { + return "This command rotates an AI API key by ID, generating a new value." +} +func (c *AIAPIKeyRotateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyRotateCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + if c.ID == "" { + return fmt.Errorf("ID is required") + } + + id, err := v3.ParseUUID(c.ID) + if err != nil { + return fmt.Errorf("invalid AI API key ID: %w", err) + } + + resp, err := client.RotateAIAPIKey(ctx, id) + if err != nil { + return err + } + + if !globalstate.Quiet { + fmt.Fprintf(os.Stderr, "Store this new API key value securely.\n\n") + fmt.Fprintf(os.Stdout, "Value: %s\n", resp.Value) + } + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyRotateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_rotate_test.go b/cmd/aiservices/apikey/apikey_rotate_test.go new file mode 100644 index 00000000..3b1ceb3e --- /dev/null +++ b/cmd/aiservices/apikey/apikey_rotate_test.go @@ -0,0 +1,37 @@ +package apikey + +import ( + "context" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyRotateValidationAndSuccess(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + // missing ID + c := &AIAPIKeyRotateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := c.CmdRun(nil, nil); err == nil { + t.Fatalf("expected error for missing ID") + } + + // success + c.ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("rotate api key: %v", err) + } +} diff --git a/cmd/aiservices/apikey/apikey_update.go b/cmd/aiservices/apikey/apikey_update.go new file mode 100644 index 00000000..1360f3bc --- /dev/null +++ b/cmd/aiservices/apikey/apikey_update.go @@ -0,0 +1,73 @@ +package apikey + +import ( + "fmt" + "os" + "time" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/spf13/cobra" +) + +type AIAPIKeyUpdateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"update"` + + ID string `cli-arg:"#" cli-usage:"AI API key ID"` + Name string `cli-flag:"name" cli-usage:"AI API key name"` + Scope string `cli-flag:"scope" cli-usage:"Scope: 'public' for all deployments, or a deployment UUID"` +} + +func (c *AIAPIKeyUpdateCmd) CmdAliases() []string { return exocmd.GUpdateAlias } +func (c *AIAPIKeyUpdateCmd) CmdShort() string { return "Update AI API key" } +func (c *AIAPIKeyUpdateCmd) CmdLong() string { + return "This command updates an AI API key by ID." +} +func (c *AIAPIKeyUpdateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} +func (c *AIAPIKeyUpdateCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client := globalstate.EgoscaleV3Client + + if c.ID == "" { + return fmt.Errorf("ID is required") + } + if c.Name == "" && c.Scope == "" { + return fmt.Errorf("at least one of --name or --scope is required") + } + + id, err := v3.ParseUUID(c.ID) + if err != nil { + return fmt.Errorf("invalid AI API key ID: %w", err) + } + + req := v3.UpdateAIAPIKeyRequest{} + if c.Name != "" { + req.Name = c.Name + } + if c.Scope != "" { + req.Scope = c.Scope + } + + resp, err := client.UpdateAIAPIKey(ctx, id, req) + if err != nil { + return err + } + + if !globalstate.Quiet { + fmt.Fprintf(os.Stdout, "AI API key updated.\n") + fmt.Fprintf(os.Stdout, "ID: %s\n", resp.ID) + fmt.Fprintf(os.Stdout, "Name: %s\n", resp.Name) + fmt.Fprintf(os.Stdout, "Scope: %s\n", resp.Scope) + fmt.Fprintf(os.Stdout, "Updated at: %s\n", resp.UpdatedAT.Format(time.RFC3339)) + } + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &AIAPIKeyUpdateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()})) +} diff --git a/cmd/aiservices/apikey/apikey_update_test.go b/cmd/aiservices/apikey/apikey_update_test.go new file mode 100644 index 00000000..c57f4795 --- /dev/null +++ b/cmd/aiservices/apikey/apikey_update_test.go @@ -0,0 +1,37 @@ +package apikey + +import ( + "context" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" + "github.com/exoscale/egoscale/v3/credentials" +) + +func TestAIAPIKeyUpdateValidationAndSuccess(t *testing.T) { + ts := newAPIKeyHelperServer(t) + defer ts.server.Close() + + exocmd.GContext = context.Background() + globalstate.Quiet = true + creds := credentials.NewStaticCredentials("key", "secret") + client, err := v3.NewClient(creds) + if err != nil { + t.Fatalf("new client: %v", err) + } + globalstate.EgoscaleV3Client = client.WithEndpoint(v3.Endpoint(ts.server.URL)) + + // missing flags + c := &AIAPIKeyUpdateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} + if err := c.CmdRun(nil, nil); err == nil { + t.Fatalf("expected error for missing flags") + } + + // success + c.Name = "updated-key" + if err := c.CmdRun(nil, nil); err != nil { + t.Fatalf("update api key: %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 37027313..ae62a66b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,6 +36,7 @@ var GRemoveAlias = []string{"rm"} var GDeleteAlias = []string{"del"} var GShowAlias = []string{"get"} var GCreateAlias = []string{"add"} +var GUpdateAlias = []string{"set"} var RootCmd = &cobra.Command{ Use: "exo",