From 1d78c4af15d12b75996592f19afda04d53802ea0 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Mon, 18 May 2026 12:26:28 -0400 Subject: [PATCH 1/3] Add object storage management commands and tests - Implemented commands for managing Ceph object storage instances, including listing, getting, creating, deleting, and resizing storage. - Added subcommands for managing buckets and objects within storage instances. - Created comprehensive unit tests for object storage API interactions. - Updated default API URL to include the correct path for API requests. --- README.md | 4 +- RELEASE_NOTES.md | 2 +- cmd/zcp/root/root.go | 11 + docs/command-taxonomy.md | 2 +- docs/configuration.md | 2 +- internal/api/objectstorage/objectstorage.go | 328 ++++++++++ .../api/objectstorage/objectstorage_test.go | 304 +++++++++ internal/commands/objectstorage.go | 586 ++++++++++++++++++ internal/config/config.go | 2 +- 9 files changed, 1235 insertions(+), 6 deletions(-) create mode 100644 internal/api/objectstorage/objectstorage.go create mode 100644 internal/api/objectstorage/objectstorage_test.go create mode 100644 internal/commands/objectstorage.go diff --git a/README.md b/README.md index af98b44..1ad0a82 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ZCP CLI (`zcp`) is a full-featured command-line tool for managing resources on t ### Quick Install — Linux / macOS ```bash -curl -fsSL https://raw.githubusercontent.com/zsoftly/zcp-cli/main/scripts/install.sh | bash +curl -fsSL https://github.com/zsoftly/zcp-cli/releases/latest/download/install.sh | bash ``` The script installs `zcp` to `/usr/local/bin`. You may be prompted for `sudo` access. @@ -26,7 +26,7 @@ The script installs `zcp` to `/usr/local/bin`. You may be prompted for `sudo` ac ### PowerShell — Windows ```powershell -irm https://raw.githubusercontent.com/zsoftly/zcp-cli/main/scripts/install.ps1 | iex +irm https://github.com/zsoftly/zcp-cli/releases/latest/download/install.ps1 | iex ``` ### Manual Download diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1456762..328406f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -26,7 +26,7 @@ The CLI can now run with just environment variables — no config file needed: ```bash export ZCP_BEARER_TOKEN=your-token -export ZCP_API_URL=https://api.zcp.zsoftly.ca +export ZCP_API_URL=https://api.zcp.zsoftly.ca/api zcp region list ``` diff --git a/cmd/zcp/root/root.go b/cmd/zcp/root/root.go index eeef817..6772cdf 100644 --- a/cmd/zcp/root/root.go +++ b/cmd/zcp/root/root.go @@ -35,6 +35,16 @@ Get started: zcp region list List available regions zcp instance list List your instances +Environment variables: + ZCP_BEARER_TOKEN Bearer token for zero-config or CI use + ZCP_API_URL API base URL override + ZCP_PROFILE Profile name when --profile is not provided + ZCP_PROJECT Default project slug for create commands + ZCP_REGION Default region slug for create commands + ZCP_CLOUD_PROVIDER Default cloud provider slug for create commands + ZCP_OUTPUT Default output format: table, json, or yaml + ZCP_DEBUG Enable debug output when true, 1, or yes + Documentation: https://docs.zsoftly.com/zcp-cli`, SilenceUsage: true, SilenceErrors: true, @@ -110,6 +120,7 @@ func init() { rootCmd.AddCommand(commands.NewCurrencyCmd()) rootCmd.AddCommand(commands.NewBillingCycleCmd()) rootCmd.AddCommand(commands.NewStorageCategoryCmd()) + rootCmd.AddCommand(commands.NewObjectStorageCmd()) // Flag completions — static values, no network calls rootCmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/docs/command-taxonomy.md b/docs/command-taxonomy.md index 82faf98..fe85091 100644 --- a/docs/command-taxonomy.md +++ b/docs/command-taxonomy.md @@ -1,7 +1,7 @@ # ZCP CLI Command Taxonomy (v0.0.6) **CLI name**: `zcp` -**Base URL**: `https://api.zcp.zsoftly.ca` +**Base URL**: `https://api.zcp.zsoftly.ca/api` **Authentication**: Bearer token (`--bearer-token` during profile add) --- diff --git a/docs/configuration.md b/docs/configuration.md index fa172f3..f87b1a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,7 +64,7 @@ Each profile supports the following fields: The default API URL when `api_url` is blank or omitted is: ``` -https://api.zcp.zsoftly.ca +https://api.zcp.zsoftly.ca/api ``` --- diff --git a/internal/api/objectstorage/objectstorage.go b/internal/api/objectstorage/objectstorage.go new file mode 100644 index 0000000..3828a89 --- /dev/null +++ b/internal/api/objectstorage/objectstorage.go @@ -0,0 +1,328 @@ +// Package objectstorage provides ZCP Ceph object storage API operations. +package objectstorage + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +// Region represents the region where the object storage is deployed. +type Region struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Country string `json:"country"` +} + +// CloudProvider represents the cloud provider backing the object storage. +type CloudProvider struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Slug string `json:"slug"` +} + +// Project represents the project the object storage belongs to. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// BillingCycle represents a billing cycle on an offering. +type BillingCycle struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Offering represents the billing plan attached to the object storage. +type Offering struct { + ID string `json:"id"` + Size json.Number `json:"size"` + Price string `json:"price"` + BillingCycle *BillingCycle `json:"billing_cycle"` + RenewAt string `json:"renew_at"` +} + +// ObjectStorage represents a Ceph object storage instance. +type ObjectStorage struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + Size json.Number `json:"size"` + UsedSpace json.Number `json:"used_space"` + S3Endpoint string `json:"s3_endpoint"` + ServiceName string `json:"service_name"` + ServiceDisplayName string `json:"service_display_name"` + ProjectID string `json:"project_id"` + RegionID string `json:"region_id"` + CloudProviderID string `json:"cloud_provider_id"` + CloudProviderSetupID string `json:"cloud_provider_setup_id"` + FrozenAt *string `json:"frozen_at"` + SuspendedAt *string `json:"suspended_at"` + TerminatedAt *string `json:"terminated_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Region *Region `json:"region"` + CloudProvider *CloudProvider `json:"cloud_provider"` + Project *Project `json:"project"` + Offering *Offering `json:"offering"` +} + +// Bucket represents an object storage bucket. +type Bucket struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + ObjectCount int `json:"object_count"` + Size json.Number `json:"size"` + ObjectStorageID string `json:"object_storage_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CustomPlan holds storage size for a custom (non-catalogue) plan. +type CustomPlan struct { + Storage int `json:"storage"` +} + +// CreateRequest holds parameters for creating an object storage instance. +type CreateRequest struct { + Name string `json:"name"` + Project string `json:"project"` + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + BillingCycle string `json:"billing_cycle"` + StorageCategory string `json:"storage_category"` + Plan string `json:"plan,omitempty"` + CustomPlan *CustomPlan `json:"custom_plan,omitempty"` + Coupon string `json:"coupon,omitempty"` +} + +// ResizeRequest holds parameters for resizing an object storage instance. +type ResizeRequest struct { + CustomPlan CustomPlan `json:"custom_plan"` +} + +// BucketCreateRequest holds parameters for creating a bucket. +type BucketCreateRequest struct { + Name string `json:"name"` +} + +// BucketUpdateRequest holds parameters for updating bucket settings. +type BucketUpdateRequest struct { + ACL string `json:"acl,omitempty"` +} + +// ACLUpdateRequest holds parameters for updating bucket ACL. +type ACLUpdateRequest struct { + ACL string `json:"acl"` +} + +// Object represents an object stored in a bucket. +type Object struct { + Key string `json:"key"` + Name string `json:"name"` + Size json.Number `json:"size"` + ContentType string `json:"content_type"` + LastModified string `json:"last_modified"` + IsPublic bool `json:"is_public"` + ETag string `json:"etag"` + URL string `json:"url"` +} + +// listResponse is the paginated API envelope for object storage instances. +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []ObjectStorage `json:"data"` + Total int `json:"total"` +} + +// singleResponse wraps a single object storage in an API envelope. +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data ObjectStorage `json:"data"` +} + +// bucketListResponse is the paginated API envelope for buckets. +type bucketListResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Bucket `json:"data"` + Total int `json:"total"` +} + +// bucketSingleResponse wraps a single bucket in an API envelope. +type bucketSingleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data Bucket `json:"data"` +} + +// objectListResponse is the paginated API envelope for objects. +type objectListResponse struct { + Status string `json:"status"` + Message string `json:"message"` + CurrentPage int `json:"current_page"` + Data []Object `json:"data"` + Total int `json:"total"` +} + +// objectSingleResponse wraps a single object in an API envelope. +type objectSingleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data Object `json:"data"` +} + +// Service provides object storage API operations. +type Service struct { + client *httpclient.Client +} + +// NewService creates a new object storage Service. +func NewService(client *httpclient.Client) *Service { + return &Service{client: client} +} + +// List returns all object storage instances for the account. +func (s *Service) List(ctx context.Context) ([]ObjectStorage, error) { + q := url.Values{ + "include": {"cloud_provider,region,project,offering"}, + } + var resp listResponse + if err := s.client.Get(ctx, "/object-storages", q, &resp); err != nil { + return nil, fmt.Errorf("listing object storages: %w", err) + } + return resp.Data, nil +} + +// Get returns a single object storage instance by slug. +func (s *Service) Get(ctx context.Context, slug string) (*ObjectStorage, error) { + q := url.Values{ + "include": {"cloud_provider,region,project,offering"}, + } + var resp singleResponse + if err := s.client.Get(ctx, "/object-storages/"+slug, q, &resp); err != nil { + return nil, fmt.Errorf("getting object storage %s: %w", slug, err) + } + return &resp.Data, nil +} + +// Create provisions a new object storage instance. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*ObjectStorage, error) { + var resp singleResponse + if err := s.client.Post(ctx, "/object-storages", req, &resp); err != nil { + return nil, fmt.Errorf("creating object storage: %w", err) + } + return &resp.Data, nil +} + +// Delete permanently deletes an object storage instance. +func (s *Service) Delete(ctx context.Context, slug string) error { + if err := s.client.Delete(ctx, "/object-storages/"+slug, nil); err != nil { + return fmt.Errorf("deleting object storage %s: %w", slug, err) + } + return nil +} + +// Resize changes the storage allocation of an object storage instance. +func (s *Service) Resize(ctx context.Context, slug string, storageGB int) (*ObjectStorage, error) { + req := ResizeRequest{CustomPlan: CustomPlan{Storage: storageGB}} + var resp singleResponse + if err := s.client.Post(ctx, "/object-storages/"+slug+"/resize", req, &resp); err != nil { + return nil, fmt.Errorf("resizing object storage %s: %w", slug, err) + } + return &resp.Data, nil +} + +// ListBuckets returns all buckets for an object storage instance. +func (s *Service) ListBuckets(ctx context.Context, slug string) ([]Bucket, error) { + var resp bucketListResponse + if err := s.client.Get(ctx, "/object-storages/"+slug+"/buckets", nil, &resp); err != nil { + return nil, fmt.Errorf("listing buckets for %s: %w", slug, err) + } + return resp.Data, nil +} + +// GetBucket returns a single bucket by slug within an object storage instance. +func (s *Service) GetBucket(ctx context.Context, slug, bucketSlug string) (*Bucket, error) { + var resp bucketSingleResponse + path := fmt.Sprintf("/object-storages/%s/buckets/%s", slug, bucketSlug) + if err := s.client.Get(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("getting bucket %s in %s: %w", bucketSlug, slug, err) + } + return &resp.Data, nil +} + +// CreateBucket creates a new bucket within an object storage instance. +func (s *Service) CreateBucket(ctx context.Context, slug, name string) (*Bucket, error) { + req := BucketCreateRequest{Name: name} + var resp bucketSingleResponse + if err := s.client.Post(ctx, "/object-storages/"+slug+"/buckets", req, &resp); err != nil { + return nil, fmt.Errorf("creating bucket %q in %s: %w", name, slug, err) + } + return &resp.Data, nil +} + +// DeleteBucket permanently deletes a bucket from an object storage instance. +func (s *Service) DeleteBucket(ctx context.Context, slug, bucketSlug string) error { + path := fmt.Sprintf("/object-storages/%s/buckets/%s", slug, bucketSlug) + if err := s.client.Delete(ctx, path, nil); err != nil { + return fmt.Errorf("deleting bucket %s in %s: %w", bucketSlug, slug, err) + } + return nil +} + +// UpdateBucket updates bucket settings (e.g. ACL / visibility). +func (s *Service) UpdateBucket(ctx context.Context, slug, bucketSlug string, req BucketUpdateRequest) (*Bucket, error) { + var resp bucketSingleResponse + path := fmt.Sprintf("/object-storages/%s/buckets/%s", slug, bucketSlug) + if err := s.client.Put(ctx, path, nil, req, &resp); err != nil { + return nil, fmt.Errorf("updating bucket %s in %s: %w", bucketSlug, slug, err) + } + return &resp.Data, nil +} + +// SetBucketACL sets the access control list on a bucket. +// Common values: "private", "public-read", "public-read-write", "authenticated-read". +func (s *Service) SetBucketACL(ctx context.Context, slug, bucketSlug, acl string) (*Bucket, error) { + req := ACLUpdateRequest{ACL: acl} + var resp bucketSingleResponse + path := fmt.Sprintf("/object-storages/%s/buckets/%s/acl", slug, bucketSlug) + if err := s.client.Put(ctx, path, nil, req, &resp); err != nil { + return nil, fmt.Errorf("setting ACL on bucket %s in %s: %w", bucketSlug, slug, err) + } + return &resp.Data, nil +} + +// ListObjects returns all objects in a bucket. +func (s *Service) ListObjects(ctx context.Context, slug, bucketSlug string) ([]Object, error) { + var resp objectListResponse + path := fmt.Sprintf("/object-storages/%s/buckets/%s/objects", slug, bucketSlug) + if err := s.client.Get(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("listing objects in bucket %s/%s: %w", slug, bucketSlug, err) + } + return resp.Data, nil +} + +// GetObject returns a single object by key from a bucket. +func (s *Service) GetObject(ctx context.Context, slug, bucketSlug, objectKey string) (*Object, error) { + var resp objectSingleResponse + path := fmt.Sprintf("/object-storages/%s/buckets/%s/objects/%s", slug, bucketSlug, url.PathEscape(objectKey)) + if err := s.client.Get(ctx, path, nil, &resp); err != nil { + return nil, fmt.Errorf("getting object %s in %s/%s: %w", objectKey, slug, bucketSlug, err) + } + return &resp.Data, nil +} diff --git a/internal/api/objectstorage/objectstorage_test.go b/internal/api/objectstorage/objectstorage_test.go new file mode 100644 index 0000000..d0ba9e9 --- /dev/null +++ b/internal/api/objectstorage/objectstorage_test.go @@ -0,0 +1,304 @@ +package objectstorage_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/zsoftly/zcp-cli/internal/api/objectstorage" + "github.com/zsoftly/zcp-cli/internal/httpclient" +) + +func newTestClient(t *testing.T, srv *httptest.Server) *httpclient.Client { + t.Helper() + return httpclient.New(httpclient.Options{ + BaseURL: srv.URL, + BearerToken: "test-token", + Timeout: 5 * time.Second, + }) +} + +type listResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []objectstorage.ObjectStorage `json:"data"` + Total int `json:"total"` +} + +type singleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data objectstorage.ObjectStorage `json:"data"` +} + +type bucketListResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data []objectstorage.Bucket `json:"data"` + Total int `json:"total"` +} + +type bucketSingleResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data objectstorage.Bucket `json:"data"` +} + +func TestList(t *testing.T) { + expected := []objectstorage.ObjectStorage{ + {ID: "os-1", Name: "my-storage", Slug: "my-storage-1", Status: "Active"}, + {ID: "os-2", Name: "backup-storage", Slug: "backup-storage-1", Status: "Active"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/object-storages" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "want GET", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listResponse{Status: "Success", Message: "OK", Data: expected, Total: 2}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + stores, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(stores) != 2 { + t.Fatalf("List() returned %d items, want 2", len(stores)) + } + if stores[0].ID != "os-1" { + t.Errorf("stores[0].ID = %q, want %q", stores[0].ID, "os-1") + } +} + +func TestGet(t *testing.T) { + expected := objectstorage.ObjectStorage{ID: "os-1", Name: "my-storage", Slug: "my-storage-1", Status: "Active"} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/object-storages/my-storage-1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Message: "OK", Data: expected}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + store, err := svc.Get(context.Background(), "my-storage-1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if store.ID != "os-1" { + t.Errorf("store.ID = %q, want %q", store.ID, "os-1") + } +} + +func TestCreate(t *testing.T) { + expected := objectstorage.ObjectStorage{ID: "os-new", Name: "new-storage", Slug: "new-storage-1"} + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/object-storages" { + http.Error(w, "unexpected", http.StatusBadRequest) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Message: "OK", Data: expected}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + req := objectstorage.CreateRequest{ + Name: "new-storage", + Project: "default", + CloudProvider: "ceph", + Region: "yul-1", + BillingCycle: "hourly", + StorageCategory: "premium-ssd", + CustomPlan: &objectstorage.CustomPlan{Storage: 100}, + } + store, err := svc.Create(context.Background(), req) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if store.ID != "os-new" { + t.Errorf("store.ID = %q, want %q", store.ID, "os-new") + } + if gotBody["cloud_provider"] != "ceph" { + t.Errorf("body cloud_provider = %v, want %q", gotBody["cloud_provider"], "ceph") + } + if gotBody["region"] != "yul-1" { + t.Errorf("body region = %v, want %q", gotBody["region"], "yul-1") + } + if cp, ok := gotBody["custom_plan"].(map[string]interface{}); !ok { + t.Error("body custom_plan not present or wrong type") + } else if cp["storage"] != float64(100) { + t.Errorf("body custom_plan.storage = %v, want 100", cp["storage"]) + } +} + +func TestDelete(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + err := svc.Delete(context.Background(), "my-storage-1") + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want DELETE", gotMethod) + } + if gotPath != "/object-storages/my-storage-1" { + t.Errorf("path = %q, want /object-storages/my-storage-1", gotPath) + } +} + +func TestResize(t *testing.T) { + expected := objectstorage.ObjectStorage{ID: "os-1", Slug: "my-storage-1"} + + var gotBody map[string]interface{} + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(singleResponse{Status: "Success", Message: "OK", Data: expected}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + store, err := svc.Resize(context.Background(), "my-storage-1", 200) + if err != nil { + t.Fatalf("Resize() error = %v", err) + } + if gotPath != "/object-storages/my-storage-1/resize" { + t.Errorf("path = %q, want /object-storages/my-storage-1/resize", gotPath) + } + if cp, ok := gotBody["custom_plan"].(map[string]interface{}); !ok { + t.Error("body custom_plan not present") + } else if cp["storage"] != float64(200) { + t.Errorf("body custom_plan.storage = %v, want 200", cp["storage"]) + } + if store.ID != "os-1" { + t.Errorf("store.ID = %q, want os-1", store.ID) + } +} + +func TestListBuckets(t *testing.T) { + expected := []objectstorage.Bucket{ + {ID: "b-1", Name: "my-bucket", Slug: "my-bucket"}, + {ID: "b-2", Name: "logs", Slug: "logs"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/object-storages/my-storage-1/buckets" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(bucketListResponse{Status: "Success", Message: "OK", Data: expected, Total: 2}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + buckets, err := svc.ListBuckets(context.Background(), "my-storage-1") + if err != nil { + t.Fatalf("ListBuckets() error = %v", err) + } + if len(buckets) != 2 { + t.Fatalf("ListBuckets() returned %d buckets, want 2", len(buckets)) + } + if buckets[0].Name != "my-bucket" { + t.Errorf("buckets[0].Name = %q, want my-bucket", buckets[0].Name) + } +} + +func TestGetBucket(t *testing.T) { + expected := objectstorage.Bucket{ID: "b-1", Name: "my-bucket", Slug: "my-bucket"} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/object-storages/my-storage-1/buckets/my-bucket" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(bucketSingleResponse{Status: "Success", Message: "OK", Data: expected}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + bucket, err := svc.GetBucket(context.Background(), "my-storage-1", "my-bucket") + if err != nil { + t.Fatalf("GetBucket() error = %v", err) + } + if bucket.ID != "b-1" { + t.Errorf("bucket.ID = %q, want b-1", bucket.ID) + } +} + +func TestCreateBucket(t *testing.T) { + expected := objectstorage.Bucket{ID: "b-new", Name: "new-bucket", Slug: "new-bucket"} + + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/object-storages/my-storage-1/buckets" { + http.Error(w, "unexpected", http.StatusBadRequest) + return + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(bucketSingleResponse{Status: "Success", Message: "OK", Data: expected}) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + bucket, err := svc.CreateBucket(context.Background(), "my-storage-1", "new-bucket") + if err != nil { + t.Fatalf("CreateBucket() error = %v", err) + } + if bucket.ID != "b-new" { + t.Errorf("bucket.ID = %q, want b-new", bucket.ID) + } + if gotBody["name"] != "new-bucket" { + t.Errorf("body name = %v, want new-bucket", gotBody["name"]) + } +} + +func TestDeleteBucket(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + svc := objectstorage.NewService(newTestClient(t, srv)) + err := svc.DeleteBucket(context.Background(), "my-storage-1", "my-bucket") + if err != nil { + t.Fatalf("DeleteBucket() error = %v", err) + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %q, want DELETE", gotMethod) + } + if gotPath != "/object-storages/my-storage-1/buckets/my-bucket" { + t.Errorf("path = %q, want /object-storages/my-storage-1/buckets/my-bucket", gotPath) + } +} diff --git a/internal/commands/objectstorage.go b/internal/commands/objectstorage.go new file mode 100644 index 0000000..708f69e --- /dev/null +++ b/internal/commands/objectstorage.go @@ -0,0 +1,586 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/zsoftly/zcp-cli/internal/api/objectstorage" +) + +const minObjectStorageGB = 60 + +// NewObjectStorageCmd returns the 'object-storage' cobra command. +func NewObjectStorageCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "object-storage", + Aliases: []string{"os"}, + Short: "Manage Ceph object storage instances", + } + cmd.AddCommand(newOSListCmd()) + cmd.AddCommand(newOSGetCmd()) + cmd.AddCommand(newOSCreateCmd()) + cmd.AddCommand(newOSDeleteCmd()) + cmd.AddCommand(newOSResizeCmd()) + cmd.AddCommand(newOSBucketCmd()) + cmd.AddCommand(newOSObjectCmd()) + return cmd +} + +func newOSListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List object storage instances", + Example: ` zcp object-storage list + zcp object-storage list --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + stores, err := svc.List(ctx) + if err != nil { + return fmt.Errorf("object-storage list: %w", err) + } + + headers := []string{"SLUG", "NAME", "SIZE (GB)", "USED (GB)", "STATUS", "REGION", "CREATED"} + rows := make([][]string, 0, len(stores)) + for _, s := range stores { + regionName := "" + if s.Region != nil { + regionName = s.Region.Name + } + rows = append(rows, []string{ + s.Slug, + s.Name, + s.Size.String(), + s.UsedSpace.String(), + s.Status, + regionName, + s.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +func newOSGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get details of an object storage instance", + Args: cobra.ExactArgs(1), + Example: ` zcp object-storage get my-storage-1`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + store, err := svc.Get(ctx, args[0]) + if err != nil { + return fmt.Errorf("object-storage get: %w", err) + } + + regionName := "" + if store.Region != nil { + regionName = store.Region.Name + } + projectName := "" + if store.Project != nil { + projectName = store.Project.Name + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", store.Slug}, + {"Name", store.Name}, + {"Status", store.Status}, + {"Size (GB)", store.Size.String()}, + {"Used (GB)", store.UsedSpace.String()}, + {"S3 Endpoint", store.S3Endpoint}, + {"Region", regionName}, + {"Project", projectName}, + {"Created", store.CreatedAt}, + } + return printer.PrintTable(headers, rows) + }, + } +} + +func newOSCreateCmd() *cobra.Command { + var name, project, cloudProvider, region, billingCycle, storageCategory, plan, coupon string + var storageGB int + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new object storage instance", + Example: ` zcp object-storage create --name my-storage --region yul-1 --billing-cycle hourly --storage-gb 100 + zcp object-storage create --name my-storage --region yul-1 --billing-cycle hourly --plan my-plan + zcp object-storage create --name my-storage --region yul-1 --billing-cycle hourly --storage-gb 100 --project my-project`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + project = resolveProject(project) + if project == "" { + return fmt.Errorf("--project is required (or set ZCP_PROJECT)") + } + cloudProvider = resolveCloudProvider(cloudProvider) + if cloudProvider == "" { + cloudProvider = "ceph" + } + region = resolveRegion(region) + if region == "" { + return fmt.Errorf("--region is required (or set ZCP_REGION)") + } + if billingCycle == "" { + return fmt.Errorf("--billing-cycle is required") + } + if plan == "" && storageGB == 0 { + return fmt.Errorf("either --plan or --storage-gb is required") + } + if plan != "" && storageGB != 0 { + return fmt.Errorf("--plan and --storage-gb are mutually exclusive") + } + if storageGB > 0 && storageGB < minObjectStorageGB { + return fmt.Errorf("--storage-gb must be at least %d", minObjectStorageGB) + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + req := objectstorage.CreateRequest{ + Name: name, + Project: project, + CloudProvider: cloudProvider, + Region: region, + BillingCycle: billingCycle, + StorageCategory: storageCategory, + Plan: plan, + Coupon: coupon, + } + if storageGB > 0 { + req.CustomPlan = &objectstorage.CustomPlan{Storage: storageGB} + } + + store, err := svc.Create(ctx, req) + if err != nil { + return fmt.Errorf("object-storage create: %w", err) + } + + regionName := "" + if store.Region != nil { + regionName = store.Region.Name + } + headers := []string{"SLUG", "NAME", "SIZE (GB)", "STATUS", "REGION", "CREATED"} + rows := [][]string{{ + store.Slug, + store.Name, + store.Size.String(), + store.Status, + regionName, + store.CreatedAt, + }} + return printer.PrintTable(headers, rows) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Object storage name (required)") + cmd.Flags().StringVar(&project, "project", "", "Project slug (or set ZCP_PROJECT)") + cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (default: ceph)") + cmd.Flags().StringVar(®ion, "region", "", "Region slug, e.g. yul-1 or yow-1 (or set ZCP_REGION)") + cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly (required)") + cmd.Flags().StringVar(&storageCategory, "storage-category", "premium-ssd", "Storage category slug") + cmd.Flags().StringVar(&plan, "plan", "", "Plan slug (mutually exclusive with --storage-gb)") + cmd.Flags().IntVar(&storageGB, "storage-gb", 0, "Custom storage size in GB, minimum 60 (mutually exclusive with --plan)") + cmd.Flags().StringVar(&coupon, "coupon", "", "Coupon code") + return cmd +} + +func newOSDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an object storage instance", + Args: cobra.ExactArgs(1), + Example: ` zcp object-storage delete my-storage-1 + zcp object-storage delete my-storage-1 -y`, + RunE: func(cmd *cobra.Command, args []string) error { + slug := args[0] + if !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete object storage %q? All data will be permanently lost. [y/N]: ", slug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.Delete(ctx, slug); err != nil { + return fmt.Errorf("object-storage delete: %w", err) + } + printer.Fprintf("Object storage %q deleted.\n", slug) + return nil + }, + } + return cmd +} + +func newOSResizeCmd() *cobra.Command { + var storageGB int + + cmd := &cobra.Command{ + Use: "resize ", + Short: "Resize the storage allocation of an object storage instance", + Args: cobra.ExactArgs(1), + Example: ` zcp object-storage resize my-storage-1 --storage-gb 200`, + RunE: func(cmd *cobra.Command, args []string) error { + if storageGB <= 0 || storageGB < minObjectStorageGB { + return fmt.Errorf("--storage-gb must be at least %d", minObjectStorageGB) + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + store, err := svc.Resize(ctx, args[0], storageGB) + if err != nil { + return fmt.Errorf("object-storage resize: %w", err) + } + + headers := []string{"SLUG", "NAME", "SIZE (GB)", "STATUS"} + rows := [][]string{{ + store.Slug, + store.Name, + store.Size.String(), + store.Status, + }} + return printer.PrintTable(headers, rows) + }, + } + cmd.Flags().IntVar(&storageGB, "storage-gb", 0, "New storage size in GB, minimum 60 (required)") + _ = cmd.MarkFlagRequired("storage-gb") + return cmd +} + +// newOSBucketCmd returns the 'object-storage bucket' subcommand group. +func newOSBucketCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "bucket", + Short: "Manage buckets within an object storage instance", + } + cmd.AddCommand(newOSBucketListCmd()) + cmd.AddCommand(newOSBucketGetCmd()) + cmd.AddCommand(newOSBucketCreateCmd()) + cmd.AddCommand(newOSBucketDeleteCmd()) + cmd.AddCommand(newOSBucketSetACLCmd()) + return cmd +} + +// newOSObjectCmd returns the 'object-storage object' subcommand group. +func newOSObjectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "object", + Short: "List and inspect objects in a bucket (read-only; use S3 API for upload/delete)", + } + cmd.AddCommand(newOSObjectListCmd()) + cmd.AddCommand(newOSObjectGetCmd()) + return cmd +} + +func newOSBucketListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List buckets in an object storage instance", + Args: cobra.ExactArgs(1), + Example: ` zcp object-storage bucket list my-storage-1 + zcp object-storage bucket list my-storage-1 --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + buckets, err := svc.ListBuckets(ctx, args[0]) + if err != nil { + return fmt.Errorf("object-storage bucket list: %w", err) + } + + headers := []string{"SLUG", "NAME", "OBJECTS", "SIZE (GB)", "STATUS", "CREATED"} + rows := make([][]string, 0, len(buckets)) + for _, b := range buckets { + rows = append(rows, []string{ + b.Slug, + b.Name, + fmt.Sprintf("%d", b.ObjectCount), + b.Size.String(), + b.Status, + b.CreatedAt, + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +func newOSBucketGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get details of a bucket", + Args: cobra.ExactArgs(2), + Example: ` zcp object-storage bucket get my-storage-1 my-bucket`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + bucket, err := svc.GetBucket(ctx, args[0], args[1]) + if err != nil { + return fmt.Errorf("object-storage bucket get: %w", err) + } + + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Slug", bucket.Slug}, + {"Name", bucket.Name}, + {"Status", bucket.Status}, + {"Objects", fmt.Sprintf("%d", bucket.ObjectCount)}, + {"Size (GB)", bucket.Size.String()}, + {"Created", bucket.CreatedAt}, + } + return printer.PrintTable(headers, rows) + }, + } +} + +func newOSBucketCreateCmd() *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new bucket in an object storage instance", + Args: cobra.ExactArgs(1), + Example: ` zcp object-storage bucket create my-storage-1 --name my-bucket`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return fmt.Errorf("--name is required") + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + bucket, err := svc.CreateBucket(ctx, args[0], name) + if err != nil { + return fmt.Errorf("object-storage bucket create: %w", err) + } + + headers := []string{"SLUG", "NAME", "STATUS", "CREATED"} + rows := [][]string{{ + bucket.Slug, + bucket.Name, + bucket.Status, + bucket.CreatedAt, + }} + return printer.PrintTable(headers, rows) + }, + } + cmd.Flags().StringVar(&name, "name", "", "Bucket name (required)") + return cmd +} + +func newOSBucketDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a bucket from an object storage instance", + Args: cobra.ExactArgs(2), + Example: ` zcp object-storage bucket delete my-storage-1 my-bucket + zcp object-storage bucket delete my-storage-1 my-bucket -y`, + RunE: func(cmd *cobra.Command, args []string) error { + storageSlug, bucketSlug := args[0], args[1] + if !autoApproved(cmd) { + fmt.Fprintf(os.Stderr, "Delete bucket %q from %q? All objects will be permanently lost. [y/N]: ", bucketSlug, storageSlug) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + if err := svc.DeleteBucket(ctx, storageSlug, bucketSlug); err != nil { + return fmt.Errorf("object-storage bucket delete: %w", err) + } + printer.Fprintf("Bucket %q deleted from %q.\n", bucketSlug, storageSlug) + return nil + }, + } + return cmd +} + +func newOSBucketSetACLCmd() *cobra.Command { + var acl string + + cmd := &cobra.Command{ + Use: "set-acl ", + Short: "Set the access control on a bucket", + Args: cobra.ExactArgs(2), + Example: ` zcp object-storage bucket set-acl my-storage-1 my-bucket --acl public-read + zcp object-storage bucket set-acl my-storage-1 my-bucket --acl private`, + RunE: func(cmd *cobra.Command, args []string) error { + if acl == "" { + return fmt.Errorf("--acl is required (values: private, public-read, public-read-write, authenticated-read)") + } + + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + bucket, err := svc.SetBucketACL(ctx, args[0], args[1], acl) + if err != nil { + return fmt.Errorf("object-storage bucket set-acl: %w", err) + } + + headers := []string{"SLUG", "NAME", "STATUS"} + rows := [][]string{{bucket.Slug, bucket.Name, bucket.Status}} + return printer.PrintTable(headers, rows) + }, + } + cmd.Flags().StringVar(&acl, "acl", "", "ACL value: private, public-read, public-read-write, authenticated-read (required)") + return cmd +} + +func newOSObjectListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List objects in a bucket", + Args: cobra.ExactArgs(2), + Example: ` zcp object-storage object list my-storage-1 my-bucket + zcp object-storage object list my-storage-1 my-bucket --output json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + objects, err := svc.ListObjects(ctx, args[0], args[1]) + if err != nil { + return fmt.Errorf("object-storage object list: %w", err) + } + + headers := []string{"KEY", "SIZE", "CONTENT-TYPE", "PUBLIC", "LAST MODIFIED"} + rows := make([][]string, 0, len(objects)) + for _, o := range objects { + isPublic := "no" + if o.IsPublic { + isPublic = "yes" + } + rows = append(rows, []string{ + o.Key, + o.Size.String(), + o.ContentType, + isPublic, + o.LastModified, + }) + } + return printer.PrintTable(headers, rows) + }, + } +} + +func newOSObjectGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get details of an object in a bucket", + Args: cobra.ExactArgs(3), + Example: ` zcp object-storage object get my-storage-1 my-bucket my-file.txt`, + RunE: func(cmd *cobra.Command, args []string) error { + _, client, printer, err := buildClientAndPrinter(cmd) + if err != nil { + return err + } + svc := objectstorage.NewService(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) + defer cancel() + + obj, err := svc.GetObject(ctx, args[0], args[1], args[2]) + if err != nil { + return fmt.Errorf("object-storage object get: %w", err) + } + + isPublic := "no" + if obj.IsPublic { + isPublic = "yes" + } + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"Key", obj.Key}, + {"Name", obj.Name}, + {"Size", obj.Size.String()}, + {"Content-Type", obj.ContentType}, + {"Public", isPublic}, + {"ETag", obj.ETag}, + {"URL", obj.URL}, + {"Last Modified", obj.LastModified}, + } + return printer.PrintTable(headers, rows) + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7025f0c..cee2896 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,7 @@ import ( const ( // DefaultAPIURL is the default ZCP API base URL. - DefaultAPIURL = "https://api.zcp.zsoftly.ca" + DefaultAPIURL = "https://api.zcp.zsoftly.ca/api" // DefaultTimeout is the default HTTP request timeout in seconds. DefaultTimeout = 30 ) From 05bf415bb340410fca308a04d2e65f7bdc6b8465 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Mon, 18 May 2026 12:56:43 -0400 Subject: [PATCH 2/3] chore: update Go toolchain version to 1.26.3 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0cf4f23..efc5f62 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/zsoftly/zcp-cli go 1.25.0 -toolchain go1.26.2 +toolchain go1.26.3 require ( github.com/olekukonko/tablewriter v0.0.5 From d7694ae9cfa6961a8d3047fddad33fff9d2f0e76 Mon Sep 17 00:00:00 2001 From: ditahkk Date: Fri, 22 May 2026 13:02:04 -0400 Subject: [PATCH 3/3] feat: enhance service cancellation and Kubernetes cluster creation parameters --- internal/api/billing/billing.go | 9 ++-- internal/api/instance/instance.go | 19 +++---- internal/api/kubernetes/kubernetes.go | 44 ++++++++-------- internal/api/project/project.go | 19 +++---- internal/commands/billing.go | 15 +++--- internal/commands/instance.go | 5 +- internal/commands/kubernetes.go | 76 ++++++++++++++------------- internal/commands/project.go | 15 ++---- 8 files changed, 103 insertions(+), 99 deletions(-) diff --git a/internal/api/billing/billing.go b/internal/api/billing/billing.go index 4a0592a..7ca3378 100644 --- a/internal/api/billing/billing.go +++ b/internal/api/billing/billing.go @@ -339,10 +339,11 @@ func (s *Service) ListCancelRequests(ctx context.Context) (json.RawMessage, erro // CancelServiceRequest holds parameters for service cancellation. type CancelServiceRequest struct { - ServiceName string `json:"service_name"` - Reason string `json:"reason"` - Type string `json:"type"` - Description string `json:"description,omitempty"` + ServiceName string `json:"service_name"` + Reason string `json:"reason"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + DeletePublicIP *bool `json:"delete_public_ip,omitempty"` } // CancelService submits a cancellation request for a service by subscription slug. diff --git a/internal/api/instance/instance.go b/internal/api/instance/instance.go index 9245e5d..da12dea 100644 --- a/internal/api/instance/instance.go +++ b/internal/api/instance/instance.go @@ -160,15 +160,15 @@ type StorageCategory struct { // ActivityLog represents a VM activity log entry. type ActivityLog struct { - ID string `json:"id"` - Category string `json:"category"` - Action string `json:"action"` - Status string `json:"status"` - Error string `json:"error"` - Description string `json:"description"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Project string `json:"project"` + ID json.Number `json:"id"` + Category string `json:"category"` + Action string `json:"action"` + Status string `json:"status"` + Error string `json:"error"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Project string `json:"project"` } // Addon represents a VM addon. @@ -210,6 +210,7 @@ type CreateRequest struct { StorageCategory string `json:"storage_category,omitempty"` ComputeCategory string `json:"compute_category,omitempty"` BlockstoragePlan string `json:"blockstorage_plan,omitempty"` + NetworkPlan string `json:"network_plan,omitempty"` IsVNF bool `json:"is_vnf"` IsVMPasswordRequired bool `json:"is_vm_password_required"` IsVMSSHRequired bool `json:"is_vm_ssh_required"` diff --git a/internal/api/kubernetes/kubernetes.go b/internal/api/kubernetes/kubernetes.go index e6614d1..e500346 100644 --- a/internal/api/kubernetes/kubernetes.go +++ b/internal/api/kubernetes/kubernetes.go @@ -71,27 +71,29 @@ type Project struct { // CreateRequest holds parameters for creating a Kubernetes cluster. type CreateRequest struct { - Name string `json:"name"` - Version string `json:"version"` - NodeSize int `json:"node_size"` - ControlNodes int `json:"control_nodes"` - CloudProvider string `json:"cloud_provider"` - Region string `json:"region"` - Project string `json:"project"` - BillingCycle string `json:"billing_cycle"` - EnableHA bool `json:"enable_ha"` - Networks []string `json:"networks"` - Plan string `json:"plan"` - WithPoolCard bool `json:"with_pool_card"` - IsCustomPlan bool `json:"is_custom_plan"` - CustomPlan interface{} `json:"custom_plan"` - VirtualMachine string `json:"virtual_machine"` - Coupon *string `json:"coupon"` - StorageCategory string `json:"storage_category"` - SSHKey string `json:"ssh_key"` - AuthMethod string `json:"authMethod"` - Username string `json:"username"` - Password string `json:"password"` + Name string `json:"name"` + Version string `json:"version"` + NodeSize int `json:"node_size"` + WorkerNodeSize int `json:"worker_node_size"` + ControlNodes int `json:"control_nodes"` + CloudProvider string `json:"cloud_provider"` + CloudProviderSetup string `json:"cloud_provider_setup,omitempty"` + Region string `json:"region"` + Project string `json:"project"` + BillingCycle string `json:"billing_cycle"` + EnableHA bool `json:"enable_ha"` + Networks []string `json:"networks"` + Plan string `json:"plan"` + WithPoolCard bool `json:"with_pool_card"` + IsCustomPlan bool `json:"is_custom_plan"` + CustomPlan interface{} `json:"custom_plan"` + VirtualMachine string `json:"virtual_machine"` + Coupon *string `json:"coupon"` + StorageCategory string `json:"storage_category"` + SSHKey string `json:"ssh_key"` + AuthMethod string `json:"authMethod"` + Username string `json:"username"` + Password string `json:"password"` } // UpgradeRequest holds parameters for upgrading (changing plan of) a Kubernetes cluster. diff --git a/internal/api/project/project.go b/internal/api/project/project.go index b261ad6..fceacff 100644 --- a/internal/api/project/project.go +++ b/internal/api/project/project.go @@ -66,13 +66,8 @@ type AddUserRequest struct { Role string `json:"role,omitempty"` } -// DashboardService represents a service entry on the project dashboard. -type DashboardService struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Count int `json:"count"` -} +// DashboardCounts maps service name to resource count on the project dashboard. +type DashboardCounts map[string]int // Service provides Project API operations. type Service struct { @@ -137,17 +132,17 @@ func (s *Service) Update(ctx context.Context, slug string, req UpdateRequest) (* return &p, nil } -// Dashboard returns services for a project's dashboard. -func (s *Service) Dashboard(ctx context.Context, slug string) ([]DashboardService, error) { +// Dashboard returns a map of service name → count for a project's dashboard. +func (s *Service) Dashboard(ctx context.Context, slug string) (DashboardCounts, error) { var raw json.RawMessage if err := s.client.Get(ctx, "/projects/dashboard/"+slug+"/services", nil, &raw); err != nil { return nil, fmt.Errorf("getting project dashboard %s: %w", slug, err) } - var services []DashboardService - if err := decode(raw, &services); err != nil { + var counts DashboardCounts + if err := decode(raw, &counts); err != nil { return nil, fmt.Errorf("decoding dashboard services: %w", err) } - return services, nil + return counts, nil } // ListIcons returns all available project icons. diff --git a/internal/commands/billing.go b/internal/commands/billing.go index c3b0ec6..5eec8b5 100644 --- a/internal/commands/billing.go +++ b/internal/commands/billing.go @@ -633,6 +633,7 @@ func runBillingCancelRequests(cmd *cobra.Command) error { func newBillingCancelServiceCmd() *cobra.Command { var serviceName, reason, cancelType, description string + var deletePublicIP bool cmd := &cobra.Command{ Use: "cancel-service ", Short: "Submit a cancellation request for a service", @@ -649,17 +650,18 @@ func newBillingCancelServiceCmd() *cobra.Command { if cancelType == "" { cancelType = "Immediate" } - return runBillingCancelService(cmd, args[0], serviceName, reason, cancelType, description) + return runBillingCancelService(cmd, args[0], serviceName, reason, cancelType, description, deletePublicIP) }, } cmd.Flags().StringVar(&serviceName, "service", "", "Service type (e.g. 'Virtual Machine', 'Block Storage', 'IP Address', 'Object Storage')") cmd.Flags().StringVar(&reason, "reason", "not_needed_anymore", "Reason: limit_expenses, not_needed_anymore, better_offer, not_satisfied, switch_product, other") cmd.Flags().StringVar(&cancelType, "type", "Immediate", "Cancel type: Immediate or 'End of billing period'") cmd.Flags().StringVar(&description, "description", "", "Additional description (optional)") + cmd.Flags().BoolVar(&deletePublicIP, "delete-public-ip", true, "Delete associated public IP addresses (required when VM has public IPs)") return cmd } -func runBillingCancelService(cmd *cobra.Command, slug, serviceName, reason, cancelType, description string) error { +func runBillingCancelService(cmd *cobra.Command, slug, serviceName, reason, cancelType, description string, deletePublicIP bool) error { _, client, printer, err := buildClientAndPrinter(cmd) if err != nil { return err @@ -670,10 +672,11 @@ func runBillingCancelService(cmd *cobra.Command, slug, serviceName, reason, canc defer cancel() req := billing.CancelServiceRequest{ - ServiceName: serviceName, - Reason: reason, - Type: cancelType, - Description: description, + ServiceName: serviceName, + Reason: reason, + Type: cancelType, + Description: description, + DeletePublicIP: &deletePublicIP, } if err := svc.CancelService(ctx, slug, req); err != nil { return fmt.Errorf("billing cancel-service: %w", err) diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 00177f9..b0782be 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -191,6 +191,7 @@ func newInstanceCreateCmd() *cobra.Command { storageCategory string computeCategory string blockstoragePlan string + networkPlan string wait bool ) @@ -259,6 +260,7 @@ func newInstanceCreateCmd() *cobra.Command { StorageCategory: storageCategory, ComputeCategory: computeCategory, BlockstoragePlan: blockstoragePlan, + NetworkPlan: networkPlan, } return runInstanceCreate(cmd, req, wait) }, @@ -276,6 +278,7 @@ func newInstanceCreateCmd() *cobra.Command { cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category slug (optional)") cmd.Flags().StringVar(&computeCategory, "compute-category", "", "Compute category slug (optional)") cmd.Flags().StringVar(&blockstoragePlan, "blockstorage-plan", "", "Block storage plan slug, e.g. 50-gb-2 (required)") + cmd.Flags().StringVar(&networkPlan, "network-plan", "", "Network plan slug (e.g. inet-yow, inet-yul — see: zcp plan network)") cmd.Flags().BoolVar(&wait, "wait", false, "Wait for the instance to reach Running state") return cmd } @@ -533,7 +536,7 @@ func runInstanceLogs(cmd *cobra.Command, slug string) error { rows := make([][]string, 0, len(logs)) for _, l := range logs { rows = append(rows, []string{ - l.ID, + l.ID.String(), l.Action, l.Status, l.Description, diff --git a/internal/commands/kubernetes.go b/internal/commands/kubernetes.go index 3c8ce97..e14d899 100644 --- a/internal/commands/kubernetes.go +++ b/internal/commands/kubernetes.go @@ -79,21 +79,22 @@ func runK8sClusterList(cmd *cobra.Command) error { func newK8sClusterCreateCmd() *cobra.Command { var ( - name string - version string - nodeSize int - controlNodes int - cloudProvider string - region string - project string - billingCycle string - enableHA bool - plan string - storageCategory string - sshKey string - authMethod string - username string - password string + name string + version string + nodeSize int + controlNodes int + cloudProvider string + cloudProviderSetup string + region string + project string + billingCycle string + enableHA bool + plan string + storageCategory string + sshKey string + authMethod string + username string + password string ) cmd := &cobra.Command{ @@ -137,27 +138,29 @@ func newK8sClusterCreateCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "WARNING: --ha is set but --control-nodes is %d; HA clusters typically require >= 3 control nodes\n", controlNodes) } return runK8sClusterCreate(cmd, kubernetes.CreateRequest{ - Name: name, - Version: version, - NodeSize: nodeSize, - ControlNodes: controlNodes, - CloudProvider: cloudProvider, - Region: region, - Project: project, - BillingCycle: billingCycle, - EnableHA: enableHA, - Networks: []string{}, - Plan: plan, - WithPoolCard: false, - IsCustomPlan: false, - CustomPlan: nil, - VirtualMachine: "", - Coupon: nil, - StorageCategory: storageCategory, - SSHKey: sshKey, - AuthMethod: authMethod, - Username: username, - Password: password, + Name: name, + Version: version, + NodeSize: nodeSize, + WorkerNodeSize: nodeSize, + ControlNodes: controlNodes, + CloudProvider: cloudProvider, + CloudProviderSetup: cloudProviderSetup, + Region: region, + Project: project, + BillingCycle: billingCycle, + EnableHA: enableHA, + Networks: []string{}, + Plan: plan, + WithPoolCard: false, + IsCustomPlan: false, + CustomPlan: nil, + VirtualMachine: "", + Coupon: nil, + StorageCategory: storageCategory, + SSHKey: sshKey, + AuthMethod: authMethod, + Username: username, + Password: password, }) }, } @@ -166,6 +169,7 @@ func newK8sClusterCreateCmd() *cobra.Command { cmd.Flags().IntVar(&nodeSize, "workers", 0, "Number of worker nodes (required, >= 1)") cmd.Flags().IntVar(&controlNodes, "control-nodes", 1, "Number of control plane nodes (default 1)") cmd.Flags().StringVar(&cloudProvider, "cloud-provider", "", "Cloud provider slug (required)") + cmd.Flags().StringVar(&cloudProviderSetup, "cloud-provider-setup", "", "Cloud provider setup slug, e.g. zcp-apc (required for quota resolution)") cmd.Flags().StringVar(®ion, "region", "", "Region slug (required)") cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug, e.g. hourly, monthly (required)") diff --git a/internal/commands/project.go b/internal/commands/project.go index 58f891e..ecb2593 100644 --- a/internal/commands/project.go +++ b/internal/commands/project.go @@ -250,20 +250,15 @@ func runProjectDashboard(cmd *cobra.Command, slug string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(getTimeout(cmd))*time.Second) defer cancel() - services, err := svc.Dashboard(ctx, slug) + counts, err := svc.Dashboard(ctx, slug) if err != nil { return fmt.Errorf("project dashboard: %w", err) } - headers := []string{"NAME", "TYPE", "STATUS", "COUNT"} - rows := make([][]string, 0, len(services)) - for _, s := range services { - rows = append(rows, []string{ - s.Name, - s.Type, - s.Status, - strconv.Itoa(s.Count), - }) + headers := []string{"SERVICE", "COUNT"} + rows := make([][]string, 0, len(counts)) + for name, count := range counts { + rows = append(rows, []string{name, strconv.Itoa(count)}) } return printer.PrintTable(headers, rows) }