profile.go raw
1 // Copyright 2022-2025 The sacloud/api-client-go Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package profile
16
17 import (
18 "encoding/json"
19 "fmt"
20 "os"
21 "path/filepath"
22 "strings"
23 )
24
25 const (
26 // DirectoryNameEnv プロファイルの格納先を指定する環境変数
27 DirectoryNameEnv = "SAKURACLOUD_PROFILE_DIR"
28 // DirectoryNameEnvOld プロファイルの格納先を指定する環境変数(後方互換)
29 DirectoryNameEnvOld = "USACLOUD_PROFILE_DIR"
30 // DefaultProfileName デフォルトのプロファイル名
31 DefaultProfileName = "default"
32
33 // EnableAPITraceWord TraceModeに設定する、APIトレースを有効化するためのキーワード
34 EnableAPITraceWord = "api"
35
36 // EnableHTTPTraceWord TraceModeに設定する、HTTPトレースを有効化するためのキーワード
37 EnableHTTPTraceWord = "http"
38 )
39
40 var (
41 configDirName = ".usacloud"
42 configFileName = "config.json"
43 currentFileName = "current"
44 )
45
46 // ValidateName プロファイル名が有効か検証
47 func ValidateName(profileName string, invalidRunes ...rune) error {
48 invalids := invalidRunes
49 if len(invalids) == 0 {
50 // validate profileName
51 invalids = []rune{filepath.ListSeparator, filepath.Separator}
52 }
53
54 for _, r := range invalids {
55 if strings.ContainsRune(profileName, r) {
56 return fmt.Errorf("got invalid profile name: %s", profileName)
57 }
58 }
59 return nil
60 }
61
62 func loadProfileDirFromEnvs() (string, error) {
63 dir, err := loadProfileDirFromEnv(DirectoryNameEnv)
64 if err != nil {
65 return "", err
66 }
67 if dir == "" {
68 // fallback
69 dir, err = loadProfileDirFromEnv(DirectoryNameEnvOld)
70 if err != nil {
71 return "", err
72 }
73 }
74 return dir, nil
75 }
76
77 func loadProfileDirFromEnv(key string) (string, error) {
78 if path, ok := os.LookupEnv(key); ok {
79 if err := ValidateName(path, filepath.ListSeparator); err != nil {
80 return "", fmt.Errorf("loading ProfileDir from environment variables[%s] is failed: %s", key, err)
81 }
82 return filepath.Clean(path), nil
83 }
84 return "", nil
85 }
86
87 func baseDir() (string, error) {
88 // from profileDirEnv var
89 path, err := loadProfileDirFromEnvs()
90 if path != "" || err != nil {
91 return path, err
92 }
93
94 // default, use homedir
95 homeDir, err := os.UserHomeDir()
96 if err != nil {
97 return "", fmt.Errorf("getting user's home dir is failed: %s", err)
98 }
99 return homeDir, nil
100 }
101
102 // ConfigDir プロファイルを格納するディレクトリのフルパス
103 func ConfigDir() (string, error) {
104 baseDir, err := baseDir()
105 if err != nil {
106 return "", fmt.Errorf("getting profile base dir is failed: %s", err)
107 }
108 return filepath.Clean(filepath.Join(baseDir, configDirName)), nil
109 }
110
111 // ConfigFilePath 指定のプロファイル名のコンフィグファイルパスを取得
112 func ConfigFilePath(profileName string) (string, error) {
113 if err := ValidateName(profileName); err != nil {
114 return "", err
115 }
116
117 if profileName == "" {
118 profileName = DefaultProfileName
119 }
120 baseDir, err := baseDir()
121 if err != nil {
122 return "", fmt.Errorf("getting profile base dir is failed: %s", err)
123 }
124 return filepath.Clean(filepath.Join(baseDir, configDirName, filepath.Clean(profileName), configFileName)), nil
125 }
126
127 // ConfigValue プロファイル コンフィグ
128 type ConfigValue struct {
129 // AccessToken アクセストークン
130 AccessToken string
131 // AccessTokenSecret アクセスシークレット
132 AccessTokenSecret string
133
134 // Zone デフォルトゾーン
135 Zone string
136 // Zones 利用可能なゾーン
137 Zones []string
138
139 // UserAgent ユーザーエージェント
140 UserAgent string `json:",omitempty"`
141 // AcceptLanguage リクエスト時のAccept-Languageヘッダ
142 AcceptLanguage string
143 // Gzip Gzip圧縮の有効化
144 Gzip bool
145
146 // RetryMax 423/503時のリトライ回数
147 RetryMax int
148 // RetryMin 423/503時のリトライ間隔(最小) 単位:秒
149 RetryWaitMin int
150 // RetryMax 423/503時のリトライ間隔(最大) 単位:秒
151 RetryWaitMax int
152
153 // StatePollingTimeout StatePollWaiterでのタイムアウト 単位:秒
154 StatePollingTimeout int
155 // StatePollingInterval StatePollWaiterでのポーリング間隔 単位:秒
156 StatePollingInterval int
157
158 // HTTPRequestTimeout APIリクエスト時のHTTPタイムアウト 単位:秒
159 HTTPRequestTimeout int
160 // HTTPRequestRateLimit APIリクエスト時の1秒あたりのリクエスト上限数
161 HTTPRequestRateLimit int
162
163 // APIRootURL APIのルートURL
164 APIRootURL string
165
166 // DefaultZone グローバルリソースAPIを呼ぶ際に指定するゾーン
167 DefaultZone string
168
169 // TraceMode トレースモード
170 TraceMode string
171 // FakeMode フェイクモード有効化
172 FakeMode bool
173 // FakeStorePath フェイクモードでのファイルストアパス
174 FakeStorePath string
175 }
176
177 func (o *ConfigValue) EnableHTTPTrace() bool {
178 return EnableHTTPTrace(o.TraceMode)
179 }
180
181 func (o *ConfigValue) EnableAPITrace() bool {
182 return EnableAPITrace(o.TraceMode)
183 }
184
185 func traceModeValue(strTraceMode string) string {
186 return strings.ToLower(strings.TrimSpace(strTraceMode))
187 }
188
189 func EnableHTTPTrace(strTraceMode string) bool {
190 traceMode := traceModeValue(strTraceMode)
191 if traceMode == "" {
192 return false
193 }
194
195 // TraceModeが"api"の場合はfalseにする(TraceMode=1などの場合はAPI/HTTP両方が有効になる)
196 if traceMode == EnableAPITraceWord {
197 return false
198 }
199 return true
200 }
201
202 func EnableAPITrace(strTraceMode string) bool {
203 traceMode := traceModeValue(strTraceMode)
204 if traceMode == "" {
205 return false
206 }
207
208 // TraceModeが"http"の場合はfalseにする(TraceMode=1などの場合はAPI/HTTP両方が有効になる)
209 if traceMode == EnableHTTPTraceWord || traceMode == "error" {
210 return false
211 }
212 return true
213 }
214
215 // Save プロファイルコンフィグを保存
216 func Save(profileName string, val interface{}) error {
217 if val == nil {
218 return fmt.Errorf("config is required")
219 }
220
221 path, err := ConfigFilePath(profileName)
222 if err != nil {
223 return err
224 }
225
226 // create dir
227 dir := filepath.Dir(path)
228 if _, err := os.Stat(dir); err != nil {
229 err := os.MkdirAll(dir, 0755)
230 if err != nil {
231 return fmt.Errorf("creating profile directory[%q] is failed: %s", dir, err)
232 }
233 }
234
235 rawBody, err := json.MarshalIndent(val, "", " ")
236 if err != nil {
237 return fmt.Errorf("marshalling config to JSON is failed: %s", err)
238 }
239
240 // merge new value if current config exists
241 if _, err := os.Stat(path); err == nil {
242 currentData, err := os.ReadFile(filepath.Clean(path))
243 if err != nil {
244 return fmt.Errorf("reading current config %q failed: %s", path, err)
245 }
246 var currentDataMap map[string]interface{}
247 if err := json.Unmarshal(currentData, ¤tDataMap); err != nil {
248 return fmt.Errorf("unmarshaling current config %q failed: %s", path, err)
249 }
250
251 var newDataMap map[string]interface{}
252 if err := json.Unmarshal(rawBody, &newDataMap); err != nil {
253 return fmt.Errorf("unmarshaling new config %q failed: %s", path, err)
254 }
255
256 // merge
257 for k, v := range newDataMap {
258 currentDataMap[k] = v
259 }
260
261 rawBody, err = json.MarshalIndent(currentDataMap, "", " ")
262 if err != nil {
263 return fmt.Errorf("marshalling new config to JSON failed: %s", err)
264 }
265 }
266
267 err = os.WriteFile(path, rawBody, 0600)
268 if err != nil {
269 return fmt.Errorf("writing config to %q is failed: %s", path, err)
270 }
271
272 return nil
273 }
274
275 // Load 指定のプロファイル名からロードする
276 //
277 // configValueには*profile.ConfigValue(派生)への参照を渡す
278 //
279 // 指定したプロファイル名に対応するコンフィグファイルが存在しない場合はエラーを返す
280 // ただしデフォルトのプロファイル名の場合はファイルが存在しなくてもエラーにしない
281 func Load(profileName string, configValue interface{}) error {
282 filePath, err := ConfigFilePath(profileName)
283 if err != nil {
284 return err
285 }
286
287 // file exists?
288 if _, err := os.Stat(filePath); err == nil {
289 // read file
290 buf, err := os.ReadFile(filepath.Clean(filePath))
291 if err != nil {
292 return fmt.Errorf("loading config from %q is failed: %s", filePath, err)
293 }
294 if err := json.Unmarshal(buf, configValue); err != nil {
295 return fmt.Errorf("parsing config is failed: %s", err)
296 }
297 } else if profileName != DefaultProfileName {
298 return fmt.Errorf("profile %q is not exists", profileName)
299 }
300
301 return nil
302 }
303
304 // Remove 指定のプロファイルのコンフィグを削除する
305 //
306 // プロファイルディレクトリが空になる場合はディレクトリも合わせて削除する
307 // Currentプロファイルが削除された場合はCurrentをデフォルトに設定する
308 func Remove(profileName string) error {
309 path, err := ConfigFilePath(profileName)
310 if err != nil {
311 return err
312 }
313
314 dir := filepath.Dir(path)
315 if _, err := os.Stat(dir); err != nil {
316 return fmt.Errorf("removing directory is failed: %q is not exists", dir)
317 }
318
319 if _, err := os.Stat(path); err != nil {
320 return fmt.Errorf("removing config is failed: %q is not exists", path)
321 }
322
323 // remove file
324 if err := os.Remove(path); err != nil {
325 return fmt.Errorf("removing config %q is failed: %s", path, err)
326 }
327
328 // remove dir if dir is empty
329 info, err := os.ReadDir(dir)
330 if err != nil {
331 return fmt.Errorf("removing config file is failed: reading %q is failed: %s", dir, err)
332 }
333 if len(info) == 0 {
334 // remove dir
335 if err := os.RemoveAll(dir); err != nil {
336 return fmt.Errorf("removing config dir %q is failed: %s", dir, err)
337 }
338 }
339
340 current, err := CurrentName()
341 if err != nil {
342 return fmt.Errorf("removing config is failed: CurrentName() returns error: %s", err)
343 }
344
345 if current == profileName {
346 if err := SetCurrentName(DefaultProfileName); err != nil {
347 return fmt.Errorf("removing config is failed: SetCurrentName() returns error: %s", err)
348 }
349 }
350 return nil
351 }
352
353 // CurrentName カレントプロファイル名
354 func CurrentName() (string, error) {
355 baseDir, err := baseDir()
356 if err != nil {
357 return "", err
358 }
359
360 profNameFile := filepath.Join(baseDir, configDirName, currentFileName)
361 if _, err := os.Stat(profNameFile); err == nil {
362 data, err := os.ReadFile(filepath.Clean(profNameFile))
363 if err != nil {
364 return "", fmt.Errorf("reading current profile is failed: %s", err)
365 }
366 profileName := string(data)
367 if err := ValidateName(profileName); err != nil {
368 return "", err
369 }
370
371 profileName = cleanupProfileName(profileName)
372 if profileName == "" {
373 profileName = DefaultProfileName
374 }
375 return profileName, nil
376 }
377
378 return DefaultProfileName, nil
379 }
380
381 func cleanupProfileName(profileName string) string {
382 targets := []string{" ", "\t", "\n"}
383 res := profileName
384 for _, s := range targets {
385 res = strings.ReplaceAll(res, s, "")
386 }
387 return strings.Trim(res, " ")
388 }
389
390 // SetCurrentName カレントプロファイル名を設定する
391 func SetCurrentName(profileName string) error {
392 if err := ValidateName(profileName); err != nil {
393 return err
394 }
395
396 profileName = cleanupProfileName(profileName)
397
398 baseDir, err := baseDir()
399 if err != nil {
400 return err
401 }
402
403 configDir := filepath.Join(baseDir, configDirName)
404 if _, err := os.Stat(configDir); err != nil {
405 err := os.MkdirAll(configDir, 0755)
406 if err != nil {
407 return fmt.Errorf("creating config dir %q is failed: %s", configDir, err)
408 }
409 }
410
411 if profileName != DefaultProfileName {
412 profileConfigPath := filepath.Join(configDir, profileName, configFileName)
413 if _, err := os.Stat(profileConfigPath); err != nil {
414 return fmt.Errorf("profile %q is not exists", profileName)
415 }
416 }
417
418 profNameFile := filepath.Join(baseDir, configDirName, currentFileName)
419 if err := os.WriteFile(profNameFile, []byte(profileName), 0600); err != nil {
420 return fmt.Errorf("writing profile to %q is failed: %s", profNameFile, err)
421 }
422
423 return nil
424 }
425
426 // List プロファイル名の一覧を返す
427 func List() ([]string, error) {
428 res := []string{"default"}
429
430 // get profile dirs under base dir
431 baseDir, err := baseDir()
432 if err != nil {
433 return []string{}, fmt.Errorf("listing profiles is failed: %s", err)
434 }
435 configDirPath := filepath.Join(baseDir, configDirName)
436 if _, err := os.Stat(configDirPath); err != nil {
437 return res, nil
438 }
439 entries, err := os.ReadDir(filepath.Join(baseDir, configDirName))
440 if err != nil {
441 return []string{}, fmt.Errorf("listing profiles is failed: %s", err)
442 }
443
444 // validate each profile dir
445 for _, fi := range entries {
446 if fi.IsDir() {
447 profile := filepath.Base(fi.Name())
448 if profile != DefaultProfileName {
449 if profile != DefaultProfileName {
450 c := &ConfigValue{}
451 if err := Load(profile, c); err == nil {
452 res = append(res, profile)
453 }
454 }
455 }
456 }
457 }
458
459 return res, nil
460 }
461