Skip to content

Commit 9b02909

Browse files
feat: add create subaccount command (#716)
Co-authored-by: Ville Välimäki <110451292+villevsv-upcloud@users.noreply.github.com>
1 parent 65e778c commit 9b02909

3 files changed

Lines changed: 518 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package account
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
8+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
9+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
10+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
11+
"github.com/spf13/cobra"
12+
"github.com/spf13/pflag"
13+
)
14+
15+
// CreateCommand creates the "account create" command
16+
func CreateCommand() commands.Command {
17+
return &createCommand{
18+
BaseCommand: commands.New(
19+
"create",
20+
"Create a sub-account",
21+
"upctl account create --username newuser --password superSecret123",
22+
`upctl account create --username newuser --password superSecret123 \
23+
--first-name John --last-name Doe \
24+
--allow-gui \
25+
--ip-filter 1.2.3.4 \
26+
--permission server:00000000-0000-0000-0000-000000000001`,
27+
),
28+
}
29+
}
30+
31+
type createCommand struct {
32+
*commands.BaseCommand
33+
username string
34+
password string
35+
firstName string
36+
lastName string
37+
phone string
38+
email string
39+
timezone string
40+
currency string
41+
allowAPI bool
42+
allowGUI bool
43+
ipFilters []string
44+
permissions []string
45+
}
46+
47+
// InitCommand implements Command.InitCommand
48+
func (s *createCommand) InitCommand() {
49+
fs := &pflag.FlagSet{}
50+
51+
fs.StringVar(&s.username, "username", "", "Sub-account username.")
52+
fs.StringVar(&s.password, "password", "", "Sub-account password. Minimum 12 characters with 1 lowercase, 1 uppercase, and 1 number.")
53+
fs.StringVar(&s.firstName, "first-name", "", "Sub-account first name.")
54+
fs.StringVar(&s.lastName, "last-name", "", "Sub-account last name.")
55+
fs.StringVar(&s.phone, "phone", "", "Phone number in international format (e.g. +358.91234567). Defaults to the main account value.")
56+
fs.StringVar(&s.email, "email", "", "Email address. Defaults to the main account value.")
57+
fs.StringVar(&s.timezone, "timezone", "", "Timezone. Defaults to the main account value.")
58+
fs.StringVar(&s.currency, "currency", "", "EUR/GBP/USD/SGD are the only accepted values. Defaults to the main account value.")
59+
fs.BoolVar(&s.allowAPI, "allow-api", true, "Allow API access for the sub-account.")
60+
fs.BoolVar(&s.allowGUI, "allow-gui", false, "Allow GUI (control panel) access for the sub-account.")
61+
fs.StringArrayVar(&s.ipFilters, "ip-filter", []string{}, "Restrict API/GUI access to this IP address. Can be specified multiple times.\n"+
62+
"Example: --ip-filter 1.2.3.4\n\n--ip-filter 5.6.7.8")
63+
fs.StringArrayVar(&s.permissions, "permission", []string{}, "Grant a permission to the sub-account in 'target_type:target_identifier' format. Can be specified multiple times.\n"+
64+
"Example: --permission server:00000000-0000-0000-0000-000000000001")
65+
66+
s.AddFlags(fs)
67+
68+
commands.Must(s.Cobra().MarkFlagRequired("username"))
69+
commands.Must(s.Cobra().MarkFlagRequired("password"))
70+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("username", cobra.NoFileCompletions))
71+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("password", cobra.NoFileCompletions))
72+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("first-name", cobra.NoFileCompletions))
73+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("last-name", cobra.NoFileCompletions))
74+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("phone", cobra.NoFileCompletions))
75+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("email", cobra.NoFileCompletions))
76+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("timezone", cobra.NoFileCompletions))
77+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("currency", cobra.NoFileCompletions))
78+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("ip-filter", cobra.NoFileCompletions))
79+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("permission", cobra.NoFileCompletions))
80+
}
81+
82+
type parsedPermission struct {
83+
targetType string
84+
targetIdentifier string
85+
}
86+
87+
// ExecuteWithoutArguments implements commands.NoArgumentCommand
88+
func (s *createCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
89+
// Validate permission flags
90+
parsedPerms := make([]parsedPermission, 0, len(s.permissions))
91+
for _, perm := range s.permissions {
92+
parts := strings.SplitN(perm, ":", 2)
93+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
94+
return nil, fmt.Errorf("invalid permission format %q: expected 'target_type:target_identifier'", perm)
95+
}
96+
parsedPerms = append(parsedPerms, parsedPermission{targetType: parts[0], targetIdentifier: parts[1]})
97+
}
98+
99+
// Validate currency value
100+
if s.currency != "" && s.currency != "EUR" && s.currency != "GBP" && s.currency != "USD" && s.currency != "SGD" {
101+
return nil, fmt.Errorf("invalid currency %q: only 'EUR', 'GBP', 'USD', and 'SGD' are accepted", s.currency)
102+
}
103+
104+
// Fetch parent account details to use as defaults for unset fields
105+
parentAccount, err := exec.Account().GetAccount(exec.Context())
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to get parent account: %w", err)
108+
}
109+
110+
parentDetails, err := exec.Account().GetAccountDetails(exec.Context(), &request.GetAccountDetailsRequest{Username: parentAccount.UserName})
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to get parent account details: %w", err)
113+
}
114+
115+
// Apply parent account values as defaults for fields not explicitly set
116+
phone := s.phone
117+
if phone == "" {
118+
phone = parentDetails.Phone
119+
}
120+
email := s.email
121+
if email == "" {
122+
email = parentDetails.Email
123+
}
124+
timezone := s.timezone
125+
if timezone == "" {
126+
timezone = parentDetails.Timezone
127+
}
128+
129+
currency := s.currency
130+
if currency == "" {
131+
currency = parentDetails.Currency
132+
}
133+
134+
allowAPI := upcloud.FromBool(s.allowAPI)
135+
allowGUI := upcloud.FromBool(s.allowGUI)
136+
137+
// Build the IP filter list
138+
ipFilters := upcloud.AccountIPFilters{IPFilter: []string{}}
139+
ipFilters.IPFilter = append(ipFilters.IPFilter, s.ipFilters...)
140+
141+
msg := fmt.Sprintf("Creating sub-account %s", s.username)
142+
exec.PushProgressStarted(msg)
143+
144+
// Create the sub-account. The access control arrays (roles, network_access,
145+
// server_access, storage_access, tag_access) are sent as empty slices because
146+
// the API requires those fields to be present. Permissions are granted
147+
// separately below via the permissions endpoint.
148+
_, err = exec.All().CreateSubaccount(exec.Context(), &request.CreateSubaccountRequest{
149+
Subaccount: request.CreateSubaccount{
150+
Username: s.username,
151+
Password: s.password,
152+
FirstName: s.firstName,
153+
LastName: s.lastName,
154+
Phone: phone,
155+
Email: email,
156+
Timezone: timezone,
157+
Language: "en",
158+
Currency: currency,
159+
AllowAPI: allowAPI,
160+
AllowGUI: allowGUI,
161+
Roles: upcloud.AccountRoles{Role: []string{}},
162+
NetworkAccess: upcloud.AccountNetworkAccess{Network: []string{}},
163+
ServerAccess: upcloud.AccountServerAccess{Server: []upcloud.AccountServer{}},
164+
StorageAccess: upcloud.AccountStorageAccess{Storage: []string{}},
165+
TagAccess: upcloud.AccountTagAccess{Tag: []upcloud.AccountTag{}},
166+
167+
IPFilters: ipFilters,
168+
},
169+
})
170+
if err != nil {
171+
return commands.HandleError(exec, msg, err)
172+
}
173+
174+
exec.PushProgressSuccess(msg)
175+
176+
// Grant any permissions that were requested via --permission flags
177+
for _, perm := range parsedPerms {
178+
permMsg := fmt.Sprintf("Granting %s:%s permission to %s", perm.targetType, perm.targetIdentifier, s.username)
179+
exec.PushProgressStarted(permMsg)
180+
181+
_, err = exec.All().GrantPermission(exec.Context(), &request.GrantPermissionRequest{
182+
Permission: upcloud.Permission{
183+
User: s.username,
184+
TargetType: upcloud.PermissionTarget(perm.targetType),
185+
TargetIdentifier: perm.targetIdentifier,
186+
},
187+
})
188+
if err != nil {
189+
return commands.HandleError(exec, permMsg, err)
190+
}
191+
192+
exec.PushProgressSuccess(permMsg)
193+
}
194+
195+
return output.None{}, nil
196+
}

0 commit comments

Comments
 (0)