Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- In `kubernetes create`, allow waiting for cluster and its node-groups to reach running state with `--wait=all` flag. When using `--wait` or `--wait=cluster`, the command will wait only for the cluster to reach running state.
- Add `account billing` command for listing billing details.

## [3.26.0] - 2025-11-26

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
dario.cat/mergo v1.0.2
github.com/UpCloudLtd/progress v1.0.3
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1
github.com/UpCloudLtd/upcloud-go-api/v8 v8.32.0
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0
github.com/adrg/xdg v0.5.3
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/gemalto/flume v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ github.com/UpCloudLtd/progress v1.0.3 h1:8SfntHkBPyQc5BL3946Bgi9KYnQOxa5RR2EKdad
github.com/UpCloudLtd/progress v1.0.3/go.mod h1:iGxOnb9HvHW0yrLGUjHr0lxHhn7TehgWwh7a8NqK6iQ=
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1 h1:eTfQsv58ufALOk9BZ7WbS/i7pMUD11RnYYpRPsz0LdI=
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1/go.mod h1:7OtVs2UqtfvjkC1HfE+Oud0MnbMv7qUWnbEgxnTAqts=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.32.0 h1:mtxrTUWr7vB2lcv0KEpHpXAdQMl4ol024I+P+qfEIRc=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.32.0/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0 h1:18MDgUePzdapCSKwLWVL+WRawyT25yMxmV9TQ32KQJQ=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/ansel1/merry v1.5.0/go.mod h1:wUy/yW0JX0ix9GYvUbciq+bi3jW/vlKPlbpI7qdZpOw=
Expand Down
163 changes: 163 additions & 0 deletions internal/commands/account/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package account

import (
"fmt"
"sort"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
"github.com/spf13/pflag"
)

// BillingCommand creates the 'account billing' command
func BillingCommand() commands.Command {
return &billingCommand{
BaseCommand: commands.New(
"billing",
"Show billing information",
"upctl account billing --year 2025 --month 7",
),
}
}

type billingCommand struct {
*commands.BaseCommand
year int
month int
resourceID string
username string
}

// InitCommand implements Command.InitCommand
func (s *billingCommand) InitCommand() {
flagSet := &pflag.FlagSet{}

flagSet.IntVar(&s.year, "year", 0, "Year for billing information.")
flagSet.IntVar(&s.month, "month", 0, "Month for billing information.")
flagSet.StringVar(&s.resourceID, "resource-id", "", "For IP addresses: the address itself, others, resource UUID")
flagSet.StringVar(&s.username, "username", "", "Valid username")

s.AddFlags(flagSet)

commands.Must(s.Cobra().MarkFlagRequired("year"))
commands.Must(s.Cobra().MarkFlagRequired("month"))
}

func firstElementAsString(row output.TableRow) string {
s, ok := row[0].(string)
if !ok {
return ""
}
return s
}

// ExecuteWithoutArguments implements commands.NoArgumentCommand
func (s *billingCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
if s.year < 1900 || s.year > 9999 {
return nil, fmt.Errorf("invalid year: %d", s.year)
}
if s.month < 1 || s.month > 12 {
return nil, fmt.Errorf("invalid month: %d", s.month)
}

svc := exec.Account()
summary, err := svc.GetBillingSummary(exec.Context(), &request.GetBillingSummaryRequest{
YearMonth: fmt.Sprintf("%d-%02d", s.year, s.month),
ResourceID: s.resourceID,
Username: s.username,
})
if err != nil {
return nil, err
}

createCategorySections := func() []output.CombinedSection {
var sections []output.CombinedSection
var summaryRows []output.TableRow

categories := map[string]*upcloud.BillingCategory{
"Servers": summary.Servers,
"Managed Databases": summary.ManagedDatabases,
"Managed Object Storages": summary.ManagedObjectStorages,
"Managed Load Balancers": summary.ManagedLoadbalancers,
"Managed Kubernetes": summary.ManagedKubernetes,
"Network Gateways": summary.NetworkGateways,
"Networks": summary.Networks,
"Storages": summary.Storages,
}

for categoryName, category := range categories {
if category != nil {
summaryRows = append(summaryRows, output.TableRow{categoryName, category.TotalAmount})
resourceGroups := map[string]*upcloud.BillingResourceGroup{
"Server": category.Server,
"Managed Database": category.ManagedDatabase,
"Managed Object Storage": category.ManagedObjectStorage,
"Managed Load Balancer": category.ManagedLoadbalancer,
"Managed Kubernetes": category.ManagedKubernetes,
"Network Gateway": category.NetworkGateway,
"IPv4 Address": category.IPv4Address,
"Backup": category.Backup,
"Storage": category.Storage,
"Template": category.Template,
}

for groupName, group := range resourceGroups {
if group != nil && len(group.Resources) > 0 {
var resourceRows []output.TableRow
for _, resource := range group.Resources {
resourceRows = append(resourceRows, output.TableRow{
resource.ResourceID,
resource.Amount,
resource.Hours,
})
}

sections = append(sections, output.CombinedSection{
Key: fmt.Sprintf("%s_%s_resources", categoryName, groupName),
Title: fmt.Sprintf("%s - %s Resources:", categoryName, groupName),
Contents: output.Table{
Columns: []output.TableColumn{
{Key: "resource_id", Header: "Resource ID"},
{Key: "amount", Header: "Amount"},
{Key: "hours", Header: "Hours"},
},
Rows: resourceRows,
EmptyMessage: fmt.Sprintf("No resources for %s.", groupName),
},
})
}
}
}
}

sort.Slice(summaryRows, func(i, j int) bool {
return firstElementAsString(summaryRows[i]) < firstElementAsString(summaryRows[j])
})
summaryRows = append(summaryRows, output.TableRow{"Total", summary.TotalAmount})

sort.Slice(sections, func(i, j int) bool {
return sections[i].Title < sections[j].Title
})
sections = append([]output.CombinedSection{{
Key: "summary",
Title: "Summary:",
Contents: output.Table{
Columns: []output.TableColumn{
{Key: "resource", Header: "Resource"},
{Key: "total_amount", Header: "Amount"},
},
Rows: summaryRows,
},
}}, sections...)
return sections
}

combined := output.Combined(createCategorySections())

return output.MarshaledWithHumanOutput{
Value: summary,
Output: combined,
}, nil
}
2 changes: 1 addition & 1 deletion internal/commands/base/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
commands.BuildCommand(account.ShowCommand(), accountCommand.Cobra(), conf)
commands.BuildCommand(account.ListCommand(), accountCommand.Cobra(), conf)
commands.BuildCommand(account.DeleteCommand(), accountCommand.Cobra(), conf)
commands.BuildCommand(account.BillingCommand(), accountCommand.Cobra(), conf)

// Account permissions
permissionsCommand := commands.BuildCommand(permissions.BasePermissionsCommand(), accountCommand.Cobra(), conf)
Expand Down Expand Up @@ -282,7 +283,6 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
allCommand := commands.BuildCommand(all.BaseAllCommand(), rootCmd, conf)
commands.BuildCommand(all.PurgeCommand(), allCommand.Cobra(), conf)
commands.BuildCommand(all.ListCommand(), allCommand.Cobra(), conf)

// Stack operations
stackCommand := commands.BuildCommand(stack.BaseStackCommand(), rootCmd, conf)
stackDeployCommand := commands.BuildCommand(stack.DeployCommand(), stackCommand.Cobra(), conf)
Expand Down
16 changes: 16 additions & 0 deletions internal/mock/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,22 @@ func (m *Service) DetachManagedObjectStorageUserPolicy(ctx context.Context, r *r
return nil
}

func (m *Service) CreateManagedObjectStoragePolicyVersion(ctx context.Context, r *request.CreateManagedObjectStoragePolicyVersionRequest) (*upcloud.ManagedObjectStoragePolicyVersion, error) {
return nil, nil
}

func (m *Service) GetManagedObjectStoragePolicyVersion(ctx context.Context, r *request.GetManagedObjectStoragePolicyVersionRequest) (*upcloud.ManagedObjectStoragePolicyVersion, error) {
return nil, nil
}

func (m *Service) GetManagedObjectStoragePolicyVersions(ctx context.Context, r *request.GetManagedObjectStoragePolicyVersionsRequest) ([]upcloud.ManagedObjectStoragePolicyVersion, error) {
return nil, nil
}

func (m *Service) DeleteManagedObjectStoragePolicyVersion(ctx context.Context, r *request.DeleteManagedObjectStoragePolicyVersionRequest) error {
return nil
}

func (m *Service) CreateManagedObjectStorageCustomDomain(ctx context.Context, r *request.CreateManagedObjectStorageCustomDomainRequest) error {
return m.Called(r).Error(0)
}
Expand Down
Loading