11package server
22
33import (
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
1930type 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+ }
0 commit comments