tools/mig-scaler/config.go (229 lines of code) (raw):
/*
Copyright 2022 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"errors"
"fmt"
"io"
"log"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-ini/ini"
"go.uber.org/multierr"
)
type MIGRef struct {
Project string
Region string
Zone string
Name string
}
func (ref MIGRef) Validate() error {
var err error
err = multierr.Append(err, require("project", ref.Project))
if ref.Region == "" && ref.Zone == "" {
err = multierr.Append(err, errors.New("required: region and/or zone"))
}
err = multierr.Append(err, require("name", ref.Name))
return err
}
func (ref MIGRef) Location() string {
if ref.Zone == "" {
return ref.Region
} else {
return ref.Zone
}
}
func (ref MIGRef) Normalize() MIGRef {
// Normalize the reference so that only region or zone is set, not both.
// Zone has a higher precedence, so if zone is set, ignore region.
var region, zone string
if ref.Zone == "" {
region = ref.Region
} else {
zone = ref.Zone
}
return MIGRef{
Project: strings.ToLower(ref.Project),
Region: strings.ToLower(region),
Zone: strings.ToLower(zone),
Name: strings.ToLower(ref.Name),
}
}
type Config struct {
// Configuration settings for the workflow, this allows a single workflow
// to control client MIGs in other projects. Generally this will be set
// via a config file as all the client MIGs will use a common workflow.
Workflow WorkflowConfig `ini:"workflow"`
// The details for the client MIG to scale up
MIG MIGConfig `ini:"mig"`
// Format specifies the format to use for the list command. Allow specifying
// this in the config file so a user can set a default.
Format string `ini:"format"`
// Include more details in the output
Detailed bool `ini:"detailed"`
// Only show active jobs for the list command
ActiveOnly bool `ini:"-"`
// --help flag has been set
Help bool `ini:"-"`
// positional command line arguments
Command string `ini:"-"`
Args []string `ini:"-"`
}
func (c *Config) Arg(i int) string {
if i < 0 || i >= len(c.Args) {
return ""
} else {
return c.Args[i]
}
}
type WorkflowConfig struct {
Project string `ini:"project"`
Region string `ini:"region"`
Name string `ini:"name"`
MaxDuration time.Duration `ini:"max-duration"`
MaxSize uint32 `ini:"max-size"`
}
func (cfg WorkflowConfig) FullName() string {
return workflowName(cfg.Project, cfg.Region, cfg.Name)
}
func (cfg *WorkflowConfig) Oldest() time.Time {
return time.Now().Add(-cfg.MaxDuration)
}
func (cfg *WorkflowConfig) Validate() error {
var err error
err = multierr.Append(err, require("workflow-project", cfg.Project))
err = multierr.Append(err, require("workflow-region", cfg.Region))
err = multierr.Append(err, require("workflow-name", cfg.Name))
if cfg.MaxDuration <= 0 {
err = multierr.Append(err, errors.New("max-duration must be greater than 0"))
}
return err
}
type MIGConfig struct {
Project string `ini:"project"`
Region string `ini:"region"`
Zone string `ini:"zone"`
Increment uint32 `ini:"increment"`
Wait time.Duration `ini:"wait"`
Duration time.Duration `ini:"duration"`
}
func (cfg *MIGConfig) Ref(name string) MIGRef {
ref := MIGRef{
Project: cfg.Project,
Region: cfg.Region,
Zone: cfg.Zone,
Name: name,
}
return ref.Normalize()
}
func readDefaultConfig(cfg *Config) error {
const name = "mig-scaler.conf"
paths := make([]string, 2)
home, _ := os.UserHomeDir()
if home != "" {
paths = append(paths, filepath.Join(home, ".config", name))
}
paths = append(paths, "/etc/"+name)
for _, p := range paths {
err := readConfig(cfg, p)
switch {
case err == nil:
return nil
case errors.Is(err, os.ErrNotExist):
continue
default:
return err
}
}
// no config file found
return nil
}
func readConfig(cfg *Config, name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
return parseConfig(cfg, f)
}
func parseConfig(cfg *Config, r io.Reader) error {
i, err := ini.Load(r)
if err != nil {
return err
}
return i.StrictMapTo(cfg)
}
func readEnv(cfg *Config) error {
var err error
envString(&cfg.Workflow.Project, "MIGSCALER_WORKFLOW_PROJECT")
envString(&cfg.Workflow.Region, "MIGSCALER_WORKFLOW_REGION")
envString(&cfg.Workflow.Name, "MIGSCALER_WORKFLOW_NAME")
err = multierr.Append(err, envDuration(&cfg.Workflow.MaxDuration, "MIGSCALER_MAX_DURATION"))
err = multierr.Append(err, envUint32(&cfg.Workflow.MaxSize, "MIGSCALER_MAX_SIZE"))
envString(&cfg.MIG.Project, "MIGSCALER_PROJECT")
var region, zone string
envString(®ion, "MIGSCALER_REGION")
envString(&zone, "MIGSCALER_ZONE")
if region != "" || zone != "" {
// Update region and zone as a pair, so that the environment variables
// can override a zone in the config file with a region.
cfg.MIG.Region = region
cfg.MIG.Zone = zone
}
err = multierr.Append(err, envUint32(&cfg.MIG.Increment, "MIGSCALER_INCREMENT"))
err = multierr.Append(err, envDuration(&cfg.MIG.Wait, "MIGSCALER_WAIT"))
err = multierr.Append(err, envDuration(&cfg.MIG.Duration, "MIGSCALER_DURATION"))
return err
}
func envString(value *string, key string) {
if s, _ := os.LookupEnv(key); s != "" {
*value = s
}
}
func envUint32(value *uint32, key string) error {
if s, _ := os.LookupEnv(key); s != "" {
i, err := parseUint32(s)
if err != nil {
return err
}
*value = i
}
return nil
}
func envDuration(value *time.Duration, key string) error {
if s, _ := os.LookupEnv(key); s != "" {
d, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("%s: invalid duration", key)
}
if d <= 0 {
return fmt.Errorf("%s: duration must be greater than 0", key)
}
*value = d
}
return nil
}
func parseUint32(s string) (uint32, error) {
v, err := strconv.ParseUint(s, 10, 32)
if err != nil {
err = fmt.Errorf("must be a valid integer between 1 and %d", math.MaxUint32)
}
return uint32(v), err
}
func defaultString(value *string, def string) {
if value == nil || *value == "" {
*value = def
}
}
func defaultDuration(value *time.Duration, def time.Duration) {
if value == nil || *value == 0 {
*value = def
}
}
func truncateDuration(value *time.Duration) {
if value == nil {
*value = 0
} else {
*value = value.Truncate(time.Second)
}
}
func require(name, value string) error {
if value == "" {
return fmt.Errorf("required: %s", name)
} else {
return nil
}
}
func printConfigError(err error) {
log.Printf("error: invalid configuration:")
for _, e := range multierr.Errors(err) {
log.Printf(" - %v", e)
}
}