Skip to content

Commit d03b2ce

Browse files
mgajdakangasta
andauthored
feat: add --prices flag to server plans command (#633)
Co-authored-by: Toni Kangas <kangasta@users.noreply.github.com>
1 parent 6f3f52e commit d03b2ce

6 files changed

Lines changed: 263 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Log API requests and responses when using global `--debug` option.
13+
- Include plan prices in `server plans` output if `--prices` flag is used.
1314

1415
### Fixed
1516

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
dario.cat/mergo v1.0.2
77
github.com/UpCloudLtd/progress v1.1.0
88
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1
9-
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0
9+
github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.0
1010
github.com/adrg/xdg v0.5.3
1111
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
1212
github.com/jedib0t/go-pretty/v6 v6.7.8

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ github.com/UpCloudLtd/progress v1.1.0 h1:RT2DNMlvJy1R8WZr2dKdDgR+xD1+3+UdZauIfWr
2828
github.com/UpCloudLtd/progress v1.1.0/go.mod h1:iGxOnb9HvHW0yrLGUjHr0lxHhn7TehgWwh7a8NqK6iQ=
2929
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1 h1:eTfQsv58ufALOk9BZ7WbS/i7pMUD11RnYYpRPsz0LdI=
3030
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1/go.mod h1:7OtVs2UqtfvjkC1HfE+Oud0MnbMv7qUWnbEgxnTAqts=
31-
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0 h1:18MDgUePzdapCSKwLWVL+WRawyT25yMxmV9TQ32KQJQ=
32-
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8=
31+
github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.0 h1:MCrAV9HwUFOzFJ0OEKIlgXefaZVXo23PuAC/ctZwfFo=
32+
github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.0/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8=
3333
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
3434
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
3535
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=

internal/commands/server/plan_list.go

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
package server
22

33
import (
4+
"fmt"
5+
"math"
46
"sort"
57
"strings"
8+
"time"
69

10+
"github.com/UpCloudLtd/progress/messages"
711
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
812
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
913
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
14+
"github.com/jedib0t/go-pretty/v6/text"
15+
"github.com/spf13/pflag"
16+
)
17+
18+
const (
19+
durationHour = "hour"
20+
durationMonth = "month"
1021
)
1122

1223
// PlanListCommand creates the "server plans" command
@@ -18,6 +29,17 @@ func PlanListCommand() commands.Command {
1829

1930
type planListCommand struct {
2031
*commands.BaseCommand
32+
pricesZone string
33+
pricesDuration string
34+
}
35+
36+
// InitCommand initializes the command flags
37+
func (s *planListCommand) InitCommand() {
38+
flagSet := &pflag.FlagSet{}
39+
flagSet.StringVar(&s.pricesZone, "prices", "", "Show prices for the specified zone (e.g., de-fra1)")
40+
flagSet.StringVar(&s.pricesDuration, "prices-duration", durationMonth, "Duration for prices calculation (e.g., 'hour', 'month', '1h', '24h')")
41+
42+
s.BaseCommand.Cobra().Flags().AddFlagSet(flagSet)
2143
}
2244

2345
// ExecuteWithoutArguments implements commands.NoArgumentCommand
@@ -40,6 +62,47 @@ func (s *planListCommand) ExecuteWithoutArguments(exec commands.Executor) (outpu
4062
return plans[i].StorageSize < plans[j].StorageSize
4163
})
4264

65+
// Check if prices should be shown
66+
showPrices := s.pricesZone != ""
67+
68+
// Validate that prices-duration is only used with --prices
69+
if !showPrices && s.pricesDuration != "month" {
70+
// User specified prices-duration without specifying a prices zone
71+
return nil, fmt.Errorf("--prices-duration requires --prices zone to be specified")
72+
}
73+
74+
// Fetch pricing information and parse duration if requested
75+
var prices map[string]upcloud.Price
76+
var duration time.Duration
77+
if showPrices {
78+
pricesByZone, err := exec.All().GetPricesByZone(exec.Context())
79+
switch {
80+
case err != nil:
81+
exec.PushProgressUpdate(messages.Update{
82+
Message: "Getting prices information failed. Plans are displayed without price details",
83+
Status: messages.MessageStatusWarning,
84+
Details: "Error: " + err.Error(),
85+
})
86+
// Continue without pricing - just show plans
87+
showPrices = false
88+
case pricesByZone != nil:
89+
// Find the requested zone
90+
var ok bool
91+
prices, ok = (*pricesByZone)[s.pricesZone]
92+
if !ok {
93+
return nil, fmt.Errorf("pricing zone %s not found", s.pricesZone)
94+
}
95+
default:
96+
// priceZones is nil, disable pricing
97+
showPrices = false
98+
}
99+
100+
duration, err = getDuration(s.pricesDuration)
101+
if err != nil {
102+
return nil, err
103+
}
104+
}
105+
43106
rows := make(map[string][]output.TableRow)
44107
for _, p := range plans {
45108
key := planType(p)
@@ -57,18 +120,24 @@ func (s *planListCommand) ExecuteWithoutArguments(exec commands.Executor) (outpu
57120
row = append(row, p.GPUModel, p.GPUAmount)
58121
}
59122

123+
// Add cost if requested
124+
if showPrices && prices != nil {
125+
cost := getPlanCost(p, prices, duration)
126+
row = append(row, cost)
127+
}
128+
60129
rows[key] = append(rows[key], row)
61130
}
62131

63132
return output.MarshaledWithHumanOutput{
64133
Value: plans,
65134
Output: output.Combined{
66-
planSection("general_purpose", "General purpose", rows["general_purpose"]),
67-
planSection("gpu", "GPU", rows["gpu"]),
68-
planSection("cloud_native", "Cloud native", rows["cloud_native"]),
69-
planSection("high_cpu", "High CPU", rows["high_cpu"]),
70-
planSection("high_memory", "High memory", rows["high_memory"]),
71-
planSection("developer", "Developer", rows["developer"]),
135+
planSection("general_purpose", "General purpose", rows["general_purpose"], showPrices, s.pricesDuration),
136+
planSection("gpu", "GPU", rows["gpu"], showPrices, s.pricesDuration),
137+
planSection("cloud_native", "Cloud native", rows["cloud_native"], showPrices, s.pricesDuration),
138+
planSection("high_cpu", "High CPU", rows["high_cpu"], showPrices, s.pricesDuration),
139+
planSection("high_memory", "High memory", rows["high_memory"], showPrices, s.pricesDuration),
140+
planSection("developer", "Developer", rows["developer"], showPrices, s.pricesDuration),
72141
},
73142
}, nil
74143
}
@@ -92,7 +161,7 @@ func planType(p upcloud.Plan) string {
92161
return "general_purpose"
93162
}
94163

95-
func planSection(key, title string, rows []output.TableRow) output.CombinedSection {
164+
func planSection(key, title string, rows []output.TableRow, showPricing bool, pricingDuration string) output.CombinedSection {
96165
columns := []output.TableColumn{
97166
{Key: "name", Header: "Name"},
98167
{Key: "cores", Header: "Cores"},
@@ -109,6 +178,19 @@ func planSection(key, title string, rows []output.TableRow) output.CombinedSecti
109178
)
110179
}
111180

181+
if showPricing {
182+
decimals := 3
183+
if pricingDuration == durationMonth {
184+
decimals = 2
185+
}
186+
187+
columns = append(columns, output.TableColumn{
188+
Key: "cost",
189+
Header: formatPricingHeader(pricingDuration),
190+
Format: getFormatPrice(decimals),
191+
})
192+
}
193+
112194
return output.CombinedSection{
113195
Key: key,
114196
Title: title,
@@ -118,3 +200,77 @@ func planSection(key, title string, rows []output.TableRow) output.CombinedSecti
118200
},
119201
}
120202
}
203+
204+
func getDuration(pricesDuration string) (time.Duration, error) {
205+
// Use 28 days per month for prices calculations (UpCloud bills max 28 days per month)
206+
month := 28 * 24 * time.Hour
207+
208+
// Handle special keywords first
209+
switch strings.ToLower(pricesDuration) {
210+
case durationHour:
211+
return 1 * time.Hour, nil
212+
case durationMonth:
213+
// Use 28 days per month for pricing calculations (UpCloud bills max 28 days per month)
214+
return month, nil
215+
default:
216+
// Parse as standard duration
217+
var err error
218+
duration, err := time.ParseDuration(pricesDuration)
219+
if err != nil {
220+
return time.Duration(0), fmt.Errorf("failed to parse prices-duration from duration string: %w", err)
221+
}
222+
return duration, nil
223+
}
224+
}
225+
226+
// getPlanCost calculates the cost for a given plan
227+
func getPlanCost(plan upcloud.Plan, pricing map[string]upcloud.Price, duration time.Duration) float64 {
228+
if pricing == nil {
229+
return math.NaN()
230+
}
231+
232+
fieldName := "server_plan_" + plan.Name
233+
234+
price, ok := pricing[fieldName]
235+
if !ok {
236+
return math.NaN()
237+
}
238+
239+
hourlyPrice := price.Price / 100
240+
241+
// Calculate cost for the requested duration
242+
// UpCloud bills per (starting) hour, so round up to next full hour
243+
return hourlyPrice * math.Ceil(duration.Hours())
244+
}
245+
246+
// formatPricingHeader creates a human-readable header for the cost column
247+
func formatPricingHeader(pricingDuration string) string {
248+
switch strings.ToLower(pricingDuration) {
249+
case durationHour:
250+
return "Price (per hour)"
251+
case durationMonth:
252+
return "Price (per month)"
253+
case "1h":
254+
return "Price (per hour)"
255+
case "24h":
256+
return "Price (per day)"
257+
default:
258+
// For other durations, just display the duration string
259+
return fmt.Sprintf("Price (per %s)", pricingDuration)
260+
}
261+
}
262+
263+
func getFormatPrice(decimals int) func(any) (text.Colors, string, error) {
264+
format := fmt.Sprintf("%%.%df", decimals)
265+
return func(val any) (text.Colors, string, error) {
266+
price, ok := val.(float64)
267+
if !ok {
268+
return nil, "", fmt.Errorf("cannot parse price from %T, expected string", val)
269+
}
270+
if math.IsNaN(price) {
271+
return text.Colors{text.FgHiBlack}, "unknown", nil
272+
}
273+
274+
return nil, fmt.Sprintf(format, price), nil
275+
}
276+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package server
2+
3+
import (
4+
"testing"
5+
6+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestDurationHeader(t *testing.T) {
11+
testCases := []struct {
12+
input string
13+
expectedHeader string
14+
}{
15+
{
16+
input: "hour",
17+
expectedHeader: "Price (per hour)",
18+
},
19+
{
20+
input: "month",
21+
expectedHeader: "Price (per month)",
22+
},
23+
{
24+
input: "1h",
25+
expectedHeader: "Price (per hour)",
26+
},
27+
{
28+
input: "4h30m",
29+
expectedHeader: "Price (per 4h30m)",
30+
},
31+
{
32+
input: "24h",
33+
expectedHeader: "Price (per day)",
34+
},
35+
}
36+
37+
for _, tc := range testCases {
38+
t.Run(tc.input, func(t *testing.T) {
39+
header := formatPricingHeader(tc.input)
40+
assert.Equal(t, tc.expectedHeader, header)
41+
})
42+
}
43+
}
44+
45+
func TestGetPlanCost(t *testing.T) {
46+
prices := map[string]upcloud.Price{
47+
"server_plan_1xCPU-1GB": {
48+
Amount: 1,
49+
Price: 1.0416,
50+
},
51+
}
52+
53+
testcases := []struct {
54+
plan upcloud.Plan
55+
duration string
56+
expected float64
57+
}{
58+
{
59+
plan: upcloud.Plan{Name: "1xCPU-1GB"},
60+
duration: "1h",
61+
expected: 0.010416,
62+
},
63+
{
64+
plan: upcloud.Plan{Name: "1xCPU-1GB"},
65+
duration: "2h30m",
66+
expected: 0.031248,
67+
},
68+
{
69+
plan: upcloud.Plan{Name: "1xCPU-1GB"},
70+
duration: "hour",
71+
expected: 0.010416,
72+
},
73+
{
74+
plan: upcloud.Plan{Name: "1xCPU-1GB"},
75+
duration: "month",
76+
expected: 7,
77+
},
78+
}
79+
80+
for _, tc := range testcases {
81+
name := tc.plan.Name + "-" + tc.duration
82+
t.Run(name, func(t *testing.T) {
83+
duration, err := getDuration(tc.duration)
84+
assert.NoError(t, err)
85+
86+
cost := getPlanCost(tc.plan, prices, duration)
87+
assert.InDelta(t, tc.expected, cost, 0.001)
88+
})
89+
}
90+
}

internal/mock/mock.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ func (m *Service) GetZones(context.Context) (*upcloud.Zones, error) {
8888
}
8989

9090
// GetPriceZones implements service.Zones.GetPriceZones
91-
func (m *Service) GetPriceZones(context.Context) (*upcloud.PriceZones, error) {
91+
func (m *Service) GetPriceZones(context.Context) (*upcloud.PriceZones, error) { //nolint: staticcheck // Required to implement interface
92+
return nil, nil
93+
}
94+
95+
// GetPricingByZone implements service.Zones.GetPricingByZone
96+
func (m *Service) GetPricesByZone(context.Context) (*upcloud.PricesByZone, error) {
9297
return nil, nil
9398
}
9499

0 commit comments

Comments
 (0)