From be346a2f04c817a2344c904a39066be71d470890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Rouiller-Monay?= Date: Thu, 30 Apr 2026 13:26:22 +0200 Subject: [PATCH 1/3] feat(aiservices/apikey): add ai-api-key management --- cmd/aiservices/aiservices.go | 2 + cmd/aiservices/apikey/apikey.go | 11 ++ cmd/aiservices/apikey/apikey_create.go | 63 ++++++++++ cmd/aiservices/apikey/apikey_create_test.go | 38 ++++++ cmd/aiservices/apikey/apikey_delete.go | 72 +++++++++++ cmd/aiservices/apikey/apikey_delete_test.go | 38 ++++++ cmd/aiservices/apikey/apikey_get.go | 73 +++++++++++ cmd/aiservices/apikey/apikey_get_test.go | 54 ++++++++ cmd/aiservices/apikey/apikey_helpers_test.go | 125 +++++++++++++++++++ cmd/aiservices/apikey/apikey_list.go | 69 ++++++++++ cmd/aiservices/apikey/apikey_list_test.go | 36 ++++++ cmd/aiservices/apikey/apikey_rotate.go | 59 +++++++++ cmd/aiservices/apikey/apikey_rotate_test.go | 37 ++++++ cmd/aiservices/apikey/apikey_update.go | 73 +++++++++++ cmd/aiservices/apikey/apikey_update_test.go | 37 ++++++ cmd/root.go | 1 + 16 files changed, 788 insertions(+) create mode 100644 cmd/aiservices/apikey/apikey.go create mode 100644 cmd/aiservices/apikey/apikey_create.go create mode 100644 cmd/aiservices/apikey/apikey_create_test.go create mode 100644 cmd/aiservices/apikey/apikey_delete.go create mode 100644 cmd/aiservices/apikey/apikey_delete_test.go create mode 100644 cmd/aiservices/apikey/apikey_get.go create mode 100644 cmd/aiservices/apikey/apikey_get_test.go create mode 100644 cmd/aiservices/apikey/apikey_helpers_test.go create mode 100644 cmd/aiservices/apikey/apikey_list.go create mode 100644 cmd/aiservices/apikey/apikey_list_test.go create mode 100644 cmd/aiservices/apikey/apikey_rotate.go create mode 100644 cmd/aiservices/apikey/apikey_rotate_test.go create mode 100644 cmd/aiservices/apikey/apikey_update.go create mode 100644 cmd/aiservices/apikey/apikey_update_test.go diff --git a/cmd/aiservices/aiservices.go b/cmd/aiservices/aiservices.go index f07799728..44a864d86 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 000000000..f88757193 --- /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 000000000..ad756e1fb --- /dev/null +++ b/cmd/aiservices/apikey/apikey_create.go @@ -0,0 +1,63 @@ +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, "Store this API key value securely.\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) + fmt.Fprintf(os.Stdout, "Value: %s\n", resp.Value) + } + 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 000000000..65a8cf66c --- /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 000000000..240061018 --- /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 000000000..a00b97a54 --- /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 000000000..0884a4d05 --- /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 000000000..10407bd01 --- /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 000000000..032b50b5b --- /dev/null +++ b/cmd/aiservices/apikey/apikey_helpers_test.go @@ -0,0 +1,125 @@ +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.AIAPIKeyWithValue{ + ID: v3.UUID("new-key-id"), + Name: "new-key", + Scope: "public", + CreatedAT: time.Now(), + UpdatedAT: time.Now(), + Value: "exo_ai_test_value", + }) + 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.AIAPIKeyWithValue{ + ID: v3.UUID(id), + Name: "key", + Scope: "public", + CreatedAT: time.Now(), + UpdatedAT: time.Now(), + Value: "exo_ai_rotated_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 000000000..ad4a8736c --- /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 000000000..2c2b2517b --- /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_rotate.go b/cmd/aiservices/apikey/apikey_rotate.go new file mode 100644 index 000000000..2d227ac2b --- /dev/null +++ b/cmd/aiservices/apikey/apikey_rotate.go @@ -0,0 +1,59 @@ +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, "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, "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 000000000..3b1ceb3e0 --- /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 000000000..1360f3bcf --- /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 000000000..c57f47951 --- /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 37027313b..ae62a66b9 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", From f30cef6e0ff78e3e96d6f46d1990dfa0f92c024e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Rouiller-Monay?= Date: Thu, 30 Apr 2026 16:53:57 +0200 Subject: [PATCH 2/3] feat(aiservices/apikey): add ai api-key reveal command --- cmd/aiservices/apikey/apikey_helpers_test.go | 12 ++++ cmd/aiservices/apikey/apikey_reveal.go | 59 ++++++++++++++++++++ cmd/aiservices/apikey/apikey_reveal_test.go | 37 ++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 cmd/aiservices/apikey/apikey_reveal.go create mode 100644 cmd/aiservices/apikey/apikey_reveal_test.go diff --git a/cmd/aiservices/apikey/apikey_helpers_test.go b/cmd/aiservices/apikey/apikey_helpers_test.go index 032b50b5b..daa33950d 100644 --- a/cmd/aiservices/apikey/apikey_helpers_test.go +++ b/cmd/aiservices/apikey/apikey_helpers_test.go @@ -52,6 +52,18 @@ func newAPIKeyHelperServer(t *testing.T) *apikeyHelperServer { }) return } + if r.Method == http.MethodPost && strings.HasSuffix(path, "/reveal") { + id := strings.TrimSuffix(path, "/reveal") + writeJSON(t, w, http.StatusOK, v3.AIAPIKeyWithValue{ + ID: v3.UUID(id), + Name: "key", + Scope: "public", + CreatedAT: time.Now(), + UpdatedAT: time.Now(), + Value: "exo_ai_revealed_value", + }) + return + } id := path switch r.Method { case http.MethodGet: diff --git a/cmd/aiservices/apikey/apikey_reveal.go b/cmd/aiservices/apikey/apikey_reveal.go new file mode 100644 index 000000000..087d2e9dd --- /dev/null +++ b/cmd/aiservices/apikey/apikey_reveal.go @@ -0,0 +1,59 @@ +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, "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, "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 000000000..96ca40550 --- /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) + } +} From 1210c17466baee94d09e99d14c3ecc9d6a337f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Rouiller-Monay?= Date: Mon, 4 May 2026 11:18:46 +0200 Subject: [PATCH 3/3] chore(aiservices/apikey): update AI API key responses --- cmd/aiservices/apikey/apikey_create.go | 3 +-- cmd/aiservices/apikey/apikey_helpers_test.go | 23 +++++--------------- cmd/aiservices/apikey/apikey_reveal.go | 3 --- cmd/aiservices/apikey/apikey_rotate.go | 3 --- 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/cmd/aiservices/apikey/apikey_create.go b/cmd/aiservices/apikey/apikey_create.go index ad756e1fb..bf19a0f38 100644 --- a/cmd/aiservices/apikey/apikey_create.go +++ b/cmd/aiservices/apikey/apikey_create.go @@ -49,11 +49,10 @@ func (c *AIAPIKeyCreateCmd) CmdRun(_ *cobra.Command, _ []string) error { } if !globalstate.Quiet { - fmt.Fprintf(os.Stderr, "Store this API key value securely.\n\n") + 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) - fmt.Fprintf(os.Stdout, "Value: %s\n", resp.Value) } return nil } diff --git a/cmd/aiservices/apikey/apikey_helpers_test.go b/cmd/aiservices/apikey/apikey_helpers_test.go index daa33950d..1e4209043 100644 --- a/cmd/aiservices/apikey/apikey_helpers_test.go +++ b/cmd/aiservices/apikey/apikey_helpers_test.go @@ -26,13 +26,12 @@ func newAPIKeyHelperServer(t *testing.T) *apikeyHelperServer { case http.MethodGet: writeJSON(t, w, http.StatusOK, v3.ListAIAPIKeysResponse{AIAPIKeys: ts.keys}) case http.MethodPost: - writeJSON(t, w, http.StatusOK, v3.AIAPIKeyWithValue{ + writeJSON(t, w, http.StatusOK, v3.AIAPIKey{ ID: v3.UUID("new-key-id"), Name: "new-key", Scope: "public", CreatedAT: time.Now(), UpdatedAT: time.Now(), - Value: "exo_ai_test_value", }) default: w.WriteHeader(http.StatusMethodNotAllowed) @@ -42,25 +41,15 @@ func newAPIKeyHelperServer(t *testing.T) *apikeyHelperServer { 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.AIAPIKeyWithValue{ - ID: v3.UUID(id), - Name: "key", - Scope: "public", - CreatedAT: time.Now(), - UpdatedAT: time.Now(), - Value: "exo_ai_rotated_value", + writeJSON(t, w, http.StatusOK, v3.AIAPIKeyValue{ + Value: "exo_ai_rotated_value", }) return } - if r.Method == http.MethodPost && strings.HasSuffix(path, "/reveal") { + if r.Method == http.MethodGet && strings.HasSuffix(path, "/reveal") { id := strings.TrimSuffix(path, "/reveal") - writeJSON(t, w, http.StatusOK, v3.AIAPIKeyWithValue{ - ID: v3.UUID(id), - Name: "key", - Scope: "public", - CreatedAT: time.Now(), - UpdatedAT: time.Now(), - Value: "exo_ai_revealed_value", + writeJSON(t, w, http.StatusOK, v3.AIAPIKeyValue{ + Value: "exo_ai_revealed_value", }) return } diff --git a/cmd/aiservices/apikey/apikey_reveal.go b/cmd/aiservices/apikey/apikey_reveal.go index 087d2e9dd..2520a72f0 100644 --- a/cmd/aiservices/apikey/apikey_reveal.go +++ b/cmd/aiservices/apikey/apikey_reveal.go @@ -46,9 +46,6 @@ func (c *AIAPIKeyRevealCmd) CmdRun(_ *cobra.Command, _ []string) error { if !globalstate.Quiet { fmt.Fprintf(os.Stderr, "Store this API key value securely.\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) fmt.Fprintf(os.Stdout, "Value: %s\n", resp.Value) } return nil diff --git a/cmd/aiservices/apikey/apikey_rotate.go b/cmd/aiservices/apikey/apikey_rotate.go index 2d227ac2b..7fe134f54 100644 --- a/cmd/aiservices/apikey/apikey_rotate.go +++ b/cmd/aiservices/apikey/apikey_rotate.go @@ -46,9 +46,6 @@ func (c *AIAPIKeyRotateCmd) CmdRun(_ *cobra.Command, _ []string) error { if !globalstate.Quiet { fmt.Fprintf(os.Stderr, "Store this new API key value securely.\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) fmt.Fprintf(os.Stdout, "Value: %s\n", resp.Value) } return nil