Skip to content

Commit

Permalink
new: Add support for shared Linode configuration format (#249)
Browse files Browse the repository at this point in the history
* Add linode config format
  • Loading branch information
LBGarber authored Jun 7, 2022
1 parent 6854b9d commit d8df3d8
Show file tree
Hide file tree
Showing 10 changed files with 482 additions and 5 deletions.
86 changes: 83 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import (
)

const (
// APIConfigEnvVar environment var to get path to Linode config
APIConfigEnvVar = "LINODE_CONFIG"
// APIConfigProfileEnvVar specifies the profile to use when loading from a Linode config
APIConfigProfileEnvVar = "LINODE_PROFILE"
// APIHost Linode API hostname
APIHost = "api.linode.com"
// APIHostVar environment var to check for alternate API URL
Expand Down Expand Up @@ -48,9 +52,13 @@ type Client struct {

millisecondsPerPoll time.Duration

baseURL string
apiVersion string
apiProto string
baseURL string
apiVersion string
apiProto string
selectedProfile string
loadedProfile string

configProfiles map[string]ConfigProfile

Account *Resource
AccountSettings *Resource
Expand Down Expand Up @@ -116,6 +124,11 @@ type Client struct {
Volumes *Resource
}

type EnvDefaults struct {
Token string
Profile string
}

type Request = resty.Request

func init() {
Expand Down Expand Up @@ -335,6 +348,73 @@ func NewClient(hc *http.Client) (client Client) {
return
}

// NewClientFromEnv creates a Client and initializes it with values
// from the LINODE_CONFIG file and the LINODE_TOKEN environment variable.
func NewClientFromEnv(hc *http.Client) (*Client, error) {
client := NewClient(hc)

// Users are expected to chain NewClient(...) and LoadConfig(...) to customize these options
configPath, err := resolveValidConfigPath()
if err != nil {
return nil, err
}

// Populate the token from the environment.
// Tokens should be first priority to maintain backwards compatibility
if token, ok := os.LookupEnv(APIEnvVar); ok && token != "" {
client.SetToken(token)
return &client, nil
}

if p, ok := os.LookupEnv(APIConfigEnvVar); ok {
configPath = p
} else if !ok && configPath == "" {
return nil, fmt.Errorf("no linode config file or token found")
}

configProfile := DefaultConfigProfile

if p, ok := os.LookupEnv(APIConfigProfileEnvVar); ok {
configProfile = p
}

client.selectedProfile = configProfile

// We should only load the config if the config file exists
if _, err := os.Stat(configPath); err != nil {
return nil, fmt.Errorf("error loading config file %s: %s", configPath, err)
}

err = client.preLoadConfig(configPath)
return &client, err
}

func (c *Client) preLoadConfig(configPath string) error {
if envDebug {
log.Printf("[INFO] Loading profile from %s\n", configPath)
}

if err := c.LoadConfig(&LoadConfigOptions{
Path: configPath,
SkipLoadProfile: true,
}); err != nil {
return err
}

// We don't want to load the profile until the user is actually making requests
c.OnBeforeRequest(func(request *Request) error {
if c.loadedProfile != c.selectedProfile {
if err := c.UseProfile(c.selectedProfile); err != nil {
return err
}
}

return nil
})

return nil
}

// nolint
func addResources(client *Client) {
resources := map[string]*Resource{
Expand Down
52 changes: 52 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,55 @@ func TestClient_SetAPIVersion(t *testing.T) {
t.Fatal(cmp.Diff(client.resty.HostURL, expectedHost))
}
}

func TestClient_NewFromEnv(t *testing.T) {
file := createTestConfig(t, configNewFromEnv)

// This is cool
t.Setenv(APIEnvVar, "")
t.Setenv(APIConfigEnvVar, file.Name())
t.Setenv(APIConfigProfileEnvVar, "cool")

client, err := NewClientFromEnv(nil)
if err != nil {
t.Fatal(err)
}

if client.selectedProfile != "cool" {
t.Fatalf("mismatched profile: %s != %s", client.selectedProfile, "cool")
}

if client.loadedProfile != "" {
t.Fatal("expected empty loaded profile")
}

if err := client.UseProfile("cool"); err != nil {
t.Fatal(err)
}

if client.loadedProfile != "cool" {
t.Fatal("expected cool as loaded profile")
}
}

func TestClient_NewFromEnvToken(t *testing.T) {
t.Setenv(APIEnvVar, "blah")

client, err := NewClientFromEnv(nil)
if err != nil {
t.Fatal(err)
}

if client.resty.Header.Get("Authorization") != "Bearer blah" {
t.Fatal("token not found in auth header: blah")
}
}

const configNewFromEnv = `
[default]
api_url = api.cool.linode.com
api_version = v4beta
[cool]
token = blah
`
152 changes: 152 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package linodego

import (
"fmt"
"os"
"strings"

"gopkg.in/ini.v1"
)

const (
DefaultConfigProfile = "default"
)

var DefaultConfigPaths = []string{
"%s/.config/linode",
"%s/.config/linode-cli",
}

type ConfigProfile struct {
APIToken string `ini:"token"`
APIVersion string `ini:"api_version"`
APIURL string `ini:"api_url"`
}

type LoadConfigOptions struct {
Path string
Profile string
SkipLoadProfile bool
}

// LoadConfig loads a Linode config according to the options argument.
// If no options are specified, the following defaults will be used:
// Path: ~/.config/linode
// Profile: default
func (c *Client) LoadConfig(options *LoadConfigOptions) error {
path, err := resolveValidConfigPath()
if err != nil {
return err
}

profileOption := DefaultConfigProfile

if options != nil {
if options.Path != "" {
path = options.Path
}

if options.Profile != "" {
profileOption = options.Profile
}
}

cfg, err := ini.Load(path)
if err != nil {
return err
}

defaultConfig := ConfigProfile{
APIToken: "",
APIURL: APIHost,
APIVersion: APIVersion,
}

if cfg.HasSection("default") {
err := cfg.Section("default").MapTo(&defaultConfig)
if err != nil {
return fmt.Errorf("failed to map default profile: %s", err)
}
}

result := make(map[string]ConfigProfile)

for _, profile := range cfg.Sections() {
name := strings.ToLower(profile.Name())

f := defaultConfig
if err := profile.MapTo(&f); err != nil {
return fmt.Errorf("failed to map values: %s", err)
}

result[name] = f
}

c.configProfiles = result

if !options.SkipLoadProfile {
if err := c.UseProfile(profileOption); err != nil {
return fmt.Errorf("unable to use profile %s: %s", profileOption, err)
}
}

return nil
}

// UseProfile switches client to use the specified profile.
// The specified profile must be already be loaded using client.LoadConfig(...)
func (c *Client) UseProfile(name string) error {
name = strings.ToLower(name)

profile, ok := c.configProfiles[name]
if !ok {
return fmt.Errorf("profile %s does not exist", name)
}

if profile.APIToken == "" {
return fmt.Errorf("unable to resolve linode_token for profile %s", name)
}

if profile.APIURL == "" {
return fmt.Errorf("unable to resolve linode_api_url for profile %s", name)
}

if profile.APIVersion == "" {
return fmt.Errorf("unable to resolve linode_api_version for profile %s", name)
}

c.SetToken(profile.APIToken)
c.SetBaseURL(profile.APIURL)
c.SetAPIVersion(profile.APIVersion)
c.selectedProfile = name
c.loadedProfile = name

return nil
}

func FormatConfigPath(path string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}

return fmt.Sprintf(path, homeDir), nil
}

func resolveValidConfigPath() (string, error) {
for _, cfg := range DefaultConfigPaths {
p, err := FormatConfigPath(cfg)
if err != nil {
return "", err
}

if _, err := os.Stat(p); err != nil {
continue
}

return p, err
}

// An empty result may not be an error
return "", nil
}
Loading

0 comments on commit d8df3d8

Please sign in to comment.