diff --git a/internal/cmd/cloud_project.go b/internal/cmd/cloud_project.go index e93269b8..620c8893 100644 --- a/internal/cmd/cloud_project.go +++ b/internal/cmd/cloud_project.go @@ -60,6 +60,7 @@ func init() { initCloudStorageS3Command(cloudCmd) initCloudStorageSwiftCommand(cloudCmd) initCloudVolumeCommand(cloudCmd) + initCloudShareCommand(cloudCmd) initCloudRancherCommand(cloudCmd) initCloudReferenceCmd(cloudCmd) initCloudSavingsPlanCommand(cloudCmd) diff --git a/internal/cmd/cloud_share.go b/internal/cmd/cloud_share.go new file mode 100644 index 00000000..475eb062 --- /dev/null +++ b/internal/cmd/cloud_share.go @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/ovh/ovhcloud-cli/internal/assets" + "github.com/ovh/ovhcloud-cli/internal/services/cloud" + "github.com/spf13/cobra" +) + +func initCloudShareCommand(cloudCmd *cobra.Command) { + shareCmd := &cobra.Command{ + Use: "share", + Short: "Manage shares in the given cloud project", + } + shareCmd.PersistentFlags().StringVar(&cloud.CloudProject, "cloud-project", "", "Cloud project ID") + + // Share CRUD commands + shareListCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List shares", + Run: cloud.ListCloudShares, + } + shareCmd.AddCommand(withFilterFlag(shareListCmd)) + + shareCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Get a specific share", + Run: cloud.GetShare, + Args: cobra.ExactArgs(1), + }) + + shareEditCmd := &cobra.Command{ + Use: "edit ", + Short: "Edit the given share", + Run: cloud.EditShare, + Args: cobra.ExactArgs(1), + } + shareEditCmd.Flags().StringVar(&cloud.ShareSpec.Description, "description", "", "Share description") + shareEditCmd.Flags().StringVar(&cloud.ShareSpec.Name, "name", "", "Share name") + shareEditCmd.Flags().IntVar(&cloud.ShareSpec.NewSize, "new-size", 0, "New share size (in GB)") + addInteractiveEditorFlag(shareEditCmd) + shareCmd.AddCommand(shareEditCmd) + + shareCmd.AddCommand(getShareCreateCmd()) + + shareCmd.AddCommand(&cobra.Command{ + Use: "delete ", + Short: "Delete the given share", + Run: cloud.DeleteShare, + Args: cobra.ExactArgs(1), + }) + + // Share ACL commands + shareAclCmd := &cobra.Command{ + Use: "acl", + Short: "Manage ACLs of the given share", + } + shareCmd.AddCommand(shareAclCmd) + + shareAclListCmd := &cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List ACLs of the given share", + Run: cloud.ListShareAcls, + Args: cobra.ExactArgs(1), + } + shareAclCmd.AddCommand(withFilterFlag(shareAclListCmd)) + + shareAclCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Get a specific share ACL", + Run: cloud.GetShareAcl, + Args: cobra.ExactArgs(2), + }) + + shareAclCmd.AddCommand(getShareAclCreateCmd()) + + shareAclCmd.AddCommand(&cobra.Command{ + Use: "delete ", + Short: "Delete the given share ACL", + Run: cloud.DeleteShareAcl, + Args: cobra.ExactArgs(2), + }) + + // Share Snapshot commands + shareSnapshotCmd := &cobra.Command{ + Use: "snapshot", + Short: "Manage snapshots of the given share", + } + shareCmd.AddCommand(shareSnapshotCmd) + + shareSnapshotListCmd := &cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List snapshots of the given share", + Run: cloud.ListShareSnapshots, + Args: cobra.ExactArgs(1), + } + shareSnapshotCmd.AddCommand(withFilterFlag(shareSnapshotListCmd)) + + shareSnapshotCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Get a specific share snapshot", + Run: cloud.GetShareSnapshot, + Args: cobra.ExactArgs(2), + }) + + shareSnapshotCmd.AddCommand(getShareSnapshotCreateCmd()) + + shareSnapshotCmd.AddCommand(&cobra.Command{ + Use: "delete ", + Short: "Delete the given share snapshot", + Run: cloud.DeleteShareSnapshot, + Args: cobra.ExactArgs(2), + }) + + cloudCmd.AddCommand(shareCmd) +} + +func getShareCreateCmd() *cobra.Command { + shareCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Create a new share", + Run: cloud.CreateShare, + Args: cobra.ExactArgs(1), + } + shareCreateCmd.Flags().StringVar(&cloud.ShareSpec.Description, "description", "", "Share description") + shareCreateCmd.Flags().StringVar(&cloud.ShareSpec.Name, "name", "", "Share name") + shareCreateCmd.Flags().StringVar(&cloud.ShareSpec.NetworkId, "network-id", "", "Network ID") + shareCreateCmd.Flags().IntVar(&cloud.ShareSpec.Size, "size", 0, "Share size (in GB)") + shareCreateCmd.Flags().StringVar(&cloud.ShareSpec.SnapshotId, "snapshot-id", "", "Snapshot ID to create the share from") + shareCreateCmd.Flags().StringVar(&cloud.ShareSpec.SubnetId, "subnet-id", "", "Subnet ID") + shareCreateCmd.Flags().StringVar(&cloud.ShareSpec.Type, "type", "", "Share type (standard-1az)") + + addInitParameterFileFlag(shareCreateCmd, assets.CloudOpenapiSchema, "/cloud/project/{serviceName}/region/{regionName}/share", "post", cloud.ShareCreateExample, nil) + addInteractiveEditorFlag(shareCreateCmd) + addFromFileFlag(shareCreateCmd) + shareCreateCmd.MarkFlagsMutuallyExclusive("from-file", "editor") + + return shareCreateCmd +} + +func getShareAclCreateCmd() *cobra.Command { + shareAclCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Create a new ACL for the given share", + Run: cloud.CreateShareAcl, + Args: cobra.ExactArgs(1), + } + shareAclCreateCmd.Flags().StringVar(&cloud.ShareAclSpec.AccessTo, "access-to", "", "Access to (e.g., IP address or CIDR)") + shareAclCreateCmd.Flags().StringVar(&cloud.ShareAclSpec.AccessLevel, "access-level", "", "Access level (ro, rw)") + + addInitParameterFileFlag(shareAclCreateCmd, assets.CloudOpenapiSchema, "/cloud/project/{serviceName}/region/{regionName}/share/{shareId}/acl", "post", cloud.ShareAclCreateExample, nil) + addInteractiveEditorFlag(shareAclCreateCmd) + addFromFileFlag(shareAclCreateCmd) + shareAclCreateCmd.MarkFlagsMutuallyExclusive("from-file", "editor") + + return shareAclCreateCmd +} + +func getShareSnapshotCreateCmd() *cobra.Command { + shareSnapshotCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Create a snapshot of the given share", + Run: cloud.CreateShareSnapshot, + Args: cobra.ExactArgs(1), + } + shareSnapshotCreateCmd.Flags().StringVar(&cloud.ShareSnapshotSpec.Description, "description", "", "Snapshot description") + shareSnapshotCreateCmd.Flags().StringVar(&cloud.ShareSnapshotSpec.Name, "name", "", "Snapshot name") + + addInitParameterFileFlag(shareSnapshotCreateCmd, assets.CloudOpenapiSchema, "/cloud/project/{serviceName}/region/{regionName}/share/{shareId}/snapshot", "post", cloud.ShareSnapshotCreateExample, nil) + addInteractiveEditorFlag(shareSnapshotCreateCmd) + addFromFileFlag(shareSnapshotCreateCmd) + shareSnapshotCreateCmd.MarkFlagsMutuallyExclusive("from-file", "editor") + + return shareSnapshotCreateCmd +} diff --git a/internal/services/cloud/cloud_share.go b/internal/services/cloud/cloud_share.go new file mode 100644 index 00000000..4df10c55 --- /dev/null +++ b/internal/services/cloud/cloud_share.go @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cloud + +import ( + _ "embed" + "errors" + "fmt" + "net/url" + + "github.com/ovh/ovhcloud-cli/internal/assets" + "github.com/ovh/ovhcloud-cli/internal/display" + filtersLib "github.com/ovh/ovhcloud-cli/internal/filters" + "github.com/ovh/ovhcloud-cli/internal/flags" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" + "github.com/ovh/ovhcloud-cli/internal/services/common" + "github.com/spf13/cobra" +) + +var ( + shareColumnsToDisplay = []string{"id", "name", "region", "protocol", "type", "status", "size"} + + //go:embed templates/cloud_share.tmpl + shareTemplate string + + //go:embed templates/cloud_share_acl.tmpl + shareAclTemplate string + + //go:embed templates/cloud_share_snapshot.tmpl + shareSnapshotTemplate string + + //go:embed parameter-samples/share-create.json + ShareCreateExample string + + //go:embed parameter-samples/share-acl-create.json + ShareAclCreateExample string + + //go:embed parameter-samples/share-snapshot-create.json + ShareSnapshotCreateExample string + + ShareSpec struct { + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + NetworkId string `json:"networkId,omitempty"` + NewSize int `json:"newSize,omitempty"` + Size int `json:"size,omitempty"` + SnapshotId string `json:"snapshotId,omitempty"` + SubnetId string `json:"subnetId,omitempty"` + Type string `json:"type,omitempty"` + } + + ShareAclSpec struct { + AccessLevel string `json:"accessLevel,omitempty"` + AccessTo string `json:"accessTo,omitempty"` + } + + ShareSnapshotSpec struct { + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + } +) + +// Share CRUD operations + +func ListCloudShares(_ *cobra.Command, _ []string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + // Fetch regions with share feature available + regions, err := getCloudRegionsWithFeatureAvailable(projectID, "share") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch regions with share feature available: %s", err) + return + } + + // Fetch shares in all regions + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region", projectID) + shares, err := httpLib.FetchObjectsParallel[[]map[string]any](endpoint+"/%s/share", regions, true) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch shares: %s", err) + return + } + + // Flatten shares in a single array + var allShares []map[string]any + for _, regionShares := range shares { + allShares = append(allShares, regionShares...) + } + + // Filter results + allShares, err = filtersLib.FilterLines(allShares, flags.GenericFilters) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to filter results: %s", err) + return + } + + display.RenderTable(allShares, shareColumnsToDisplay, &flags.OutputFormatConfig) +} + +func findShare(shareId string) (string, map[string]any, error) { + projectID, err := getConfiguredCloudProject() + if err != nil { + return "", nil, err + } + + // Fetch regions with share feature available + regions, err := getCloudRegionsWithFeatureAvailable(projectID, "share") + if err != nil { + return "", nil, fmt.Errorf("failed to fetch regions with share feature available: %s", err) + } + + // Search for the given share in all regions + for _, region := range regions { + var ( + share map[string]any + endpoint = fmt.Sprintf("/v1/cloud/project/%s/region/%s/share/%s", + projectID, url.PathEscape(region.(string)), url.PathEscape(shareId)) + ) + if err := httpLib.Client.Get(endpoint, &share); err == nil { + return endpoint, share, nil + } + } + + return "", nil, errors.New("no share found with given ID") +} + +func GetShare(_ *cobra.Command, args []string) { + _, share, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.OutputObject(share, args[0], shareTemplate, &flags.OutputFormatConfig) +} + +func CreateShare(cmd *cobra.Command, args []string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/share", projectID, url.PathEscape(args[0])) + task, err := common.CreateResource( + cmd, + "/cloud/project/{serviceName}/region/{regionName}/share", + endpoint, + ShareCreateExample, + ShareSpec, + assets.CloudOpenapiSchema, + []string{"name", "size", "type"}, + ) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, task, "✅ Share %s created successfully", task["id"]) +} + +func EditShare(cmd *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if err := common.EditResource( + cmd, + "/cloud/project/{serviceName}/region/{regionName}/share/{shareId}", + endpoint, + ShareSpec, + assets.CloudOpenapiSchema, + ); err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } +} + +func DeleteShare(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to delete share: %s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Share %s deleted successfully", args[0]) +} + +// Share ACL operations + +func ListShareAcls(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.ManageListRequestNoExpand(endpoint+"/acl", []string{"id", "accessType", "accessTo", "accessLevel", "status"}, flags.GenericFilters) +} + +func GetShareAcl(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.ManageObjectRequest(endpoint+"/acl", args[1], shareAclTemplate) +} + +func CreateShareAcl(cmd *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + acl, err := common.CreateResource( + cmd, + "/cloud/project/{serviceName}/region/{regionName}/share/{shareId}/acl", + endpoint+"/acl", + ShareAclCreateExample, + ShareAclSpec, + assets.CloudOpenapiSchema, + []string{"accessTo", "accessLevel"}, + ) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, acl, "✅ Share ACL %s created successfully", acl["id"]) +} + +func DeleteShareAcl(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + aclEndpoint := fmt.Sprintf("%s/acl/%s", endpoint, url.PathEscape(args[1])) + if err := httpLib.Client.Delete(aclEndpoint, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to delete share ACL: %s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Share ACL %s deleted successfully", args[1]) +} + +// Share Snapshot operations + +func ListShareSnapshots(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.ManageListRequestNoExpand(endpoint+"/snapshot", []string{"id", "name", "shareId", "status", "size"}, flags.GenericFilters) +} + +func GetShareSnapshot(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.ManageObjectRequest(endpoint+"/snapshot", args[1], shareSnapshotTemplate) +} + +func CreateShareSnapshot(cmd *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + snapshot, err := common.CreateResource( + cmd, + "/cloud/project/{serviceName}/region/{regionName}/share/{shareId}/snapshot", + endpoint+"/snapshot", + ShareSnapshotCreateExample, + ShareSnapshotSpec, + assets.CloudOpenapiSchema, + []string{}, + ) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, snapshot, "✅ Share snapshot %s created successfully", snapshot["id"]) +} + +func DeleteShareSnapshot(_ *cobra.Command, args []string) { + endpoint, _, err := findShare(args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + snapshotEndpoint := fmt.Sprintf("%s/snapshot/%s", endpoint, url.PathEscape(args[1])) + if err := httpLib.Client.Delete(snapshotEndpoint, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to delete share snapshot: %s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Share snapshot %s deleted successfully", args[1]) +} diff --git a/internal/services/cloud/parameter-samples/share-acl-create.json b/internal/services/cloud/parameter-samples/share-acl-create.json new file mode 100644 index 00000000..048e4256 --- /dev/null +++ b/internal/services/cloud/parameter-samples/share-acl-create.json @@ -0,0 +1,4 @@ +{ + "accessTo": "192.168.1.0/24", + "accessLevel": "rw" +} \ No newline at end of file diff --git a/internal/services/cloud/parameter-samples/share-create.json b/internal/services/cloud/parameter-samples/share-create.json new file mode 100644 index 00000000..4da340fc --- /dev/null +++ b/internal/services/cloud/parameter-samples/share-create.json @@ -0,0 +1,8 @@ +{ + "name": "myshare", + "description": "My shared storage", + "networkId": "00000000-0000-0000-0000-000000000000", + "size": 100, + "subnetId": "00000000-0000-0000-0000-000000000000", + "type": "standard-1az" +} \ No newline at end of file diff --git a/internal/services/cloud/parameter-samples/share-snapshot-create.json b/internal/services/cloud/parameter-samples/share-snapshot-create.json new file mode 100644 index 00000000..5648f9b9 --- /dev/null +++ b/internal/services/cloud/parameter-samples/share-snapshot-create.json @@ -0,0 +1,4 @@ +{ + "name": "myshare-snapshot", + "description": "Snapshot of my shared storage" +} \ No newline at end of file diff --git a/internal/services/cloud/templates/cloud_share.tmpl b/internal/services/cloud/templates/cloud_share.tmpl new file mode 100644 index 00000000..fa38b460 --- /dev/null +++ b/internal/services/cloud/templates/cloud_share.tmpl @@ -0,0 +1,31 @@ +🚀 Share {{.ServiceName}} +======= + +_{{index .Result "description"}}_ + +## General information + +**Name**: {{index .Result "name"}} +**ID**: {{index .Result "id"}} +**Region**: {{index .Result "region"}} +**Type**: {{index .Result "type"}} +**Protocol**: {{index .Result "protocol"}} +**Status**: {{index .Result "status"}} +**Size**: {{index .Result "size"}}GB +**Public**: {{index .Result "isPublic"}} +**Network ID**: {{index .Result "networkId"}} +**Share Network ID**: {{index .Result "shareNetworkId"}} +**Subnet ID**: {{index .Result "subnetId"}} +**Created at**: {{index .Result "createdAt"}} + +## Export locations +{{range index .Result "exportLocations"}} +- **ID**: {{.id}}, **Path**: {{.path}} +{{end}} + +## Capabilities +{{range index .Result "capabilities"}} +- {{.name}}: {{.enabled}} +{{end}} + +💡 Use option --json or --yaml to get the raw output with all information diff --git a/internal/services/cloud/templates/cloud_share_acl.tmpl b/internal/services/cloud/templates/cloud_share_acl.tmpl new file mode 100644 index 00000000..a6c21da1 --- /dev/null +++ b/internal/services/cloud/templates/cloud_share_acl.tmpl @@ -0,0 +1,14 @@ +🔐 Share ACL {{.ServiceName}} +======= + +## General information + +**ID**: {{index .Result "id"}} +**Access Type**: {{index .Result "accessType"}} +**Access To**: {{index .Result "accessTo"}} +**Access Level**: {{index .Result "accessLevel"}} +**Status**: {{index .Result "status"}} +**Created at**: {{index .Result "createdAt"}} +**Updated at**: {{index .Result "updatedAt"}} + +💡 Use option --json or --yaml to get the raw output with all information diff --git a/internal/services/cloud/templates/cloud_share_snapshot.tmpl b/internal/services/cloud/templates/cloud_share_snapshot.tmpl new file mode 100644 index 00000000..db69095a --- /dev/null +++ b/internal/services/cloud/templates/cloud_share_snapshot.tmpl @@ -0,0 +1,17 @@ +📸 Share Snapshot {{.ServiceName}} +======= + +_{{index .Result "description"}}_ + +## General information + +**Name**: {{index .Result "name"}} +**ID**: {{index .Result "id"}} +**Share ID**: {{index .Result "shareId"}} +**Share Protocol**: {{index .Result "shareProtocol"}} +**Status**: {{index .Result "status"}} +**Size**: {{index .Result "size"}}GB +**Share Size**: {{index .Result "shareSize"}}GB +**Created at**: {{index .Result "createdAt"}} + +💡 Use option --json or --yaml to get the raw output with all information