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, &currentDataMap); 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