diff --git a/config_set.go b/config_set.go new file mode 100644 index 0000000..71e96b7 --- /dev/null +++ b/config_set.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/weaveworks/footloose/pkg/cluster" + + "github.com/spf13/cobra" + "github.com/weaveworks/footloose/pkg/config" +) + +var configSetCmd = &cobra.Command{ + Use: "set", + Short: "set value to configuration", + RunE: configSet, +} + +var setOptions struct { + config string +} + +var setDryRun bool + +func init() { + configSetCmd.Flags().StringVarP(&setOptions.config, "config", "c", Footloose, "Cluster configuration file") + configSetCmd.Flags().BoolVar(&setDryRun, "dry-run", defaultDryRun, "Dry run changes") + configCmd.AddCommand(configSetCmd) +} + +func configSet(cmd *cobra.Command, args []string) error { + conf, cstr := getConfigAndCluster() + checkSetRequirements(args, cstr, conf) + err := config.SetValueToConfig(args[0], conf, config.ClarifyArg(args[1])) + if err != nil { + log.Fatalln(err) + } + handleSetResponse(conf) + return nil +} + +func getConfigAndCluster() (*config.Config, *cluster.Cluster) { + conf, err := config.NewConfigFromFile(setOptions.config) + if err != nil { + log.Fatalln(err) + } + cstr, err := cluster.New(*conf) + if err != nil { + log.Fatalln(err) + } + return conf, cstr +} + +func checkSetRequirements(args []string, c *cluster.Cluster, conf *config.Config) { + if len(args) != 2 { + log.Fatalln("set command needs 2 args") + } + err := config.IsSetValueValid(args[0], args[1]) + if err != nil { + log.Fatalln(err) + } + num, err := c.CountCreatedMachine() + if err != nil { + log.Fatalln(err) + } + if num > 0 { + log.Fatalln("cannot change config, please delete your machines before any change") + } +} + +func handleSetResponse(conf *config.Config) { + if setDryRun == true { + res, err := json.MarshalIndent(conf, "", " ") + if err != nil { + log.Fatalln(err) + } + fmt.Printf("%s", res) + } else { + conf.Save(setOptions.config) + } +} diff --git a/defaults.go b/defaults.go index faf14f8..9eba511 100644 --- a/defaults.go +++ b/defaults.go @@ -10,6 +10,8 @@ func imageTag(v string) string { return v } +var defaultDryRun = false + var defaultConfig = config.Config{ Cluster: config.Cluster{ Name: "cluster", diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index 8578372..3dc3f3f 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -301,6 +301,21 @@ func (c *Cluster) Inspect(hostnames []string) ([]*Machine, error) { return machines, nil } +// CountCreatedMachine counts how many machines are created +func (c *Cluster) CountCreatedMachine() (int, error) { + machines, err := c.gatherMachines() + if err != nil { + return 0, err + } + counter := 0 + for _, m := range machines { + if m.IsCreated() { + counter++ + } + } + return counter, nil +} + func (c *Cluster) machineFilering(machines []*Machine, hostnames []string) []*Machine { // machinesToKeep map is used to know not found machines machinesToKeep := make(map[string]bool) @@ -341,8 +356,8 @@ func (c *Cluster) gatherMachines() (machines []*Machine, err error) { } p := config.PortMapping{} hostPort, _ := strconv.Atoi(v[0].HostPort) - p.HostPort = uint16(k.Int()) - p.ContainerPort = uint16(hostPort) + p.HostPort = k.Int() + p.ContainerPort = hostPort p.Address = v[0].HostIP ports = append(ports, p) } diff --git a/pkg/config/cluster.go b/pkg/config/cluster.go index 623f040..32665b9 100644 --- a/pkg/config/cluster.go +++ b/pkg/config/cluster.go @@ -24,28 +24,37 @@ func NewConfigFromFile(path string) (*Config, error) { return NewConfigFromYAML(data) } +// Save writes the Config to a file. +func (c *Config) Save(path string) error { + data, err := yaml.Marshal(c) + if err != nil { + return err + } + return ioutil.WriteFile(path, data, 0666) +} + // MachineReplicas are a number of machine following the same specification. type MachineReplicas struct { - Spec Machine `json:"spec"` - Count int `json:"count"` + Spec Machine `json:"spec" yaml:"spec,omitempty"` + Count int `json:"count" yaml:"count,omitempty"` } // Cluster is a set of Machines. type Cluster struct { // Name is the cluster name. Defaults to "cluster". - Name string `json:"name"` + Name string `json:"name" yaml:"name,omitempty"` // PrivateKey is the path to the private SSH key used to login into the cluster // machines. Can be expanded to user homedir if ~ is found. Ex. ~/.ssh/id_rsa - PrivateKey string `json:"privateKey"` + PrivateKey string `json:"privateKey" yaml:"privateKey,omitempty"` } // Config is the top level config object. type Config struct { // Cluster describes cluster-wide configuration. - Cluster Cluster `json:"cluster"` + Cluster Cluster `json:"cluster" yaml:"cluster,omitempty"` // Machines describe the machines we want created for this cluster. - Machines []MachineReplicas `json:"machines"` + Machines []MachineReplicas `json:"machines" yaml:"machines,omitempty"` } // validate checks basic rules for MachineReplicas's fields diff --git a/pkg/config/machine.go b/pkg/config/machine.go index 237ea80..f48ab9c 100644 --- a/pkg/config/machine.go +++ b/pkg/config/machine.go @@ -10,33 +10,33 @@ import ( // Volume is a volume that can be attached to a Machine. type Volume struct { // Type is the volume type. One of "bind" or "volume". - Type string `json:"type"` + Type string `json:"type" yaml:"type,omitempty"` // Source is the volume source. // With type=bind, the volume source is a directory or a file in the host // filesystem. // With type=volume, source is either the name of a docker volume or "" for // anonymous volumes. - Source string `json:"source"` + Source string `json:"source" yaml:"source,omitempty"` // Destination is the mount point inside the container. - Destination string `json:"destination"` + Destination string `json:"destination" yaml:"destination,omitempty"` // ReadOnly specifies if the volume should be read-only or not. - ReadOnly bool `json:"readOnly"` + ReadOnly bool `json:"readOnly" yaml:"readOnly,omitempty"` } // PortMapping describes mapping a port from the machine onto the host. type PortMapping struct { // Protocol is the layer 4 protocol for this mapping. One of "tcp" or "udp". // Defaults to "tcp". - Protocol string `json:"protocol,omitempty"` + Protocol string `json:"protocol,omitempty" yaml:"protocol,omitempty"` // Address is the host address to bind to. Defaults to "0.0.0.0". - Address string `json:"address,omitempty"` + Address string `json:"address,omitempty" yaml:"address,omitempty"` // HostPort is the base host port to map the containers ports to. As we // configure a number of machine replicas, each machine will use HostPort+i // where i is between 0 and N-1, N being the number of machine replicas. If 0, // a local port will be automatically allocated. - HostPort uint16 `json:"hostPort,omitempty"` + HostPort int `json:"hostPort,omitempty" yaml:"hostPort,omitempty"` // ContainerPort is the container port to map. - ContainerPort uint16 `json:"containerPort"` + ContainerPort int `json:"containerPort" yaml:"containerPort,omitempty"` } // Machine is the machine configuration. @@ -45,26 +45,49 @@ type Machine struct { // index, a number between 0 and N-1, N being the number of machines in the // cluster. This name will also be used as the machine hostname. Defaults to // "node%d". - Name string `json:"name"` + Name string `json:"name" yaml:"name,omitempty"` // Image is the container image to use for this machine. - Image string `json:"image"` + Image string `json:"image" yaml:"image,omitempty"` // Privileged controls whether to start the Machine as a privileged container // or not. Defaults to false. - Privileged bool `json:"privileged,omitempty"` + Privileged bool `json:"privileged,omitempty" yaml:"privileged,omitempty"` // Volumes is the list of volumes attached to this machine. - Volumes []Volume `json:"volumes,omitempty"` + Volumes []Volume `json:"volumes,omitempty" yaml:"volumes,omitempty"` // PortMappings is the list of ports to expose to the host. - PortMappings []PortMapping `json:"portMappings,omitempty"` + PortMappings []PortMapping `json:"portMappings,omitempty" yaml:"portMappings,omitempty"` // Cmd is a cmd which will be run in the container. - Cmd string `json:"cmd,omitempty"` + Cmd string `json:"cmd,omitempty" yaml:"cmd,omitempty"` } // validate checks basic rules for Machine's fields -func (conf Machine) validate() error { +func (conf Machine) validate() (rerr error) { + rerr = nil validName := strings.Contains(conf.Name, "%d") if validName != true { log.Warnf("Machine conf validation: machine name %v is not valid, it should contains %%d", conf.Name) - return fmt.Errorf("Machine configuration not valid") + rerr = fmt.Errorf("Machine configuration not valid") + } + for _, pmapping := range conf.PortMappings { + if err := pmapping.validate(); err != nil { + log.Warn(err) + rerr = fmt.Errorf("Machine configuration not valid") + } + } + return rerr +} + +func (conf PortMapping) validate() error { + if conf.HostPort > maxPort || conf.HostPort < minPort { + return fmt.Errorf("Machine conf validation: hostPort %v is not valid, it cannot be hight than %v or lesser than %v", + conf.HostPort, + maxPort, + minPort) + } + if conf.ContainerPort > maxPort || conf.ContainerPort < minPort { + return fmt.Errorf("Machine conf validation: containerPort %v is not valid, it cannot be hight than %v or lesser than %v", + conf.ContainerPort, + maxPort, + minPort) } return nil } diff --git a/pkg/config/set.go b/pkg/config/set.go new file mode 100644 index 0000000..45d4dad --- /dev/null +++ b/pkg/config/set.go @@ -0,0 +1,96 @@ +package config + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" +) + +const ( + machinePattern = "%d" + minPort = 0 + maxPort = 65535 + machineNameRegex = `^(?:m|M)achines\[[0-9]+\].(?:s|S)pec.(?:n|N)ame$` + portRegex = `^(?:m|M)achines\[[0-9]+\].(?:s|S)pec.(?:p|P)ortMappings\[[0-9]+\].(?:(?:h|H)ostPort|(?:c|C)ontainerPort)$` +) + +// IsSetValueValid checks if value is valid for the given path +func IsSetValueValid(stringPath string, value string) (rerr error) { + defer func() { + if r := recover(); r != nil { + rerr = fmt.Errorf(fmt.Sprint(r)) + } + }() + v := reflect.ValueOf(ClarifyArg(value)) + if v.Kind() == reflect.String { + // check machine name + re := regexp.MustCompile(machineNameRegex) + if re.MatchString(stringPath) == true { + if strings.Contains(v.Interface().(string), machinePattern) == false { + return fmt.Errorf("Machine name is not valid, it should contain %v", machinePattern) + } + } + } else if v.Kind() == reflect.Int { + // check port value + re := regexp.MustCompile(portRegex) + if re.MatchString(stringPath) == true { + if v.Interface().(int) > maxPort || v.Interface().(int) < minPort { + return fmt.Errorf("Port cannot be higher than %v or lesset than %v", maxPort, minPort) + } + } + } + return nil +} + +// ClarifyArg converts string to int or bool if possible +func ClarifyArg(v string) interface{} { + intV, err := strconv.Atoi(v) + if err == nil { + return intV + } + boolV, err := strconv.ParseBool(v) + if err == nil { + return boolV + } + return v +} + +// SetValueToConfig sets specific value to an object given a string path +func SetValueToConfig(stringPath string, object interface{}, newValue interface{}) (rerr error) { + defer func() { + if r := recover(); r != nil { + rerr = fmt.Errorf(fmt.Sprint(r)) + } + }() + keyPath := strings.FieldsFunc(stringPath, pathSplit) + v := reflect.ValueOf(object) + for _, key := range keyPath { + keyUpper := strings.Title(key) + for v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() == reflect.Struct { + v = v.FieldByName(keyUpper) + if v.IsValid() == false { + return fmt.Errorf("%v key does not exist", keyUpper) + } + } else if v.Kind() == reflect.Slice { + index, errConv := strconv.Atoi(keyUpper) + if errConv != nil { + return fmt.Errorf("%v is not an index", key) + } + v = v.Index(index) + } else { + return fmt.Errorf("%v is neither a slice or a struct", v) + } + } + newV := reflect.ValueOf(newValue) + if v.Kind() == newV.Kind() { + v.Set(newV) + } else { + return fmt.Errorf("%v type and %v type do not correspond", v, newV) + } + return nil +} diff --git a/pkg/config/set_test.go b/pkg/config/set_test.go new file mode 100644 index 0000000..5bb7b34 --- /dev/null +++ b/pkg/config/set_test.go @@ -0,0 +1,191 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func newConfigForTest(name, key, image, machineName string, privileged bool, count int) *Config { + return &Config{ + Cluster: Cluster{Name: name, PrivateKey: key}, + Machines: []MachineReplicas{ + MachineReplicas{ + Count: count, + Spec: Machine{ + Image: image, + Name: machineName, + Privileged: privileged, + }, + }, + }, + } +} + +func TestSetValueToConfig(t *testing.T) { + + tests := []struct { + name string + stringPath string + newValue interface{} + config *Config + expectedOutput interface{} + expectedErr bool + }{ + { + "simple path set string", + "cluster.name", + "new-clu", + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("new-clu", "key", "some-image", "node%d", true, 2), + false, + }, + { + "array path set int", + "machines[0].count", + 3, + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("cluster", "key", "some-image", "node%d", true, 3), + false, + }, + { + "array path set bool", + "machines[0].spec.privileged", + false, + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("cluster", "key", "some-image", "node%d", false, 2), + false, + }, + { + "array path set bool to non bool var", + "machines[0].spec", + false, + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + true, + }, + { + "array path set int to non int var", + "cluster.name", + 1, + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + true, + }, + { + "array path set string to non string var", + "machines[0].count", + "value", + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + true, + }, + { + "array path set int to out of bound of array", + "machines[2].count", + 1, + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + newConfigForTest("cluster", "key", "some-image", "node%d", true, 2), + true, + }, + } + + for _, utest := range tests { + t.Run(utest.name, func(t *testing.T) { + err := SetValueToConfig(utest.stringPath, utest.config, utest.newValue) + if utest.expectedErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + assert.Equal(t, utest.expectedOutput, utest.config) + }) + } +} + +func TestIsSetValueValid(t *testing.T) { + tests := []struct { + name string + stringPath string + value string + expectedError bool + }{ + { + "machine name invalid", + "machines[0].spec.name", + "myMachineName", + true, + }, + { + "machine name valid", + "machines[0].spec.name", + "myMachine%dName", + false, + }, + { + "machine name invalid for uppercase path", + "Machines[0].Spec.Name", + "myMachineName", + true, + }, + { + "port too high invalid containerPort", + "machines[0].spec.portMappings[0].containerPort", + "65536", + true, + }, + { + "port too high invalid containerPort for uppercase path", + "Machines[0].Spec.PortMappings[0].ContainerPort", + "65536", + true, + }, + { + "port too high invalid hostPort", + "machines[0].spec.portMappings[0].hostPort", + "65536", + true, + }, + { + "port too high invalid hostPort for uppercase path", + "Machines[0].Spec.PortMappings[0].HostPort", + "65536", + true, + }, + { + "port lower than 1 invalid containerPort", + "machines[0].spec.portMappings[0].containerPort", + "-1", + true, + }, + { + "port lower than 1 invalid hostPort", + "machines[0].spec.portMappings[0].hostPort", + "-1", + true, + }, + { + "port valid containerPort", + "machines[0].spec.portMappings[0].containerPort", + "22", + false, + }, + { + "port valid hostPort", + "machines[0].spec.portMappings[0].hostPort", + "22", + false, + }, + } + + for _, utest := range tests { + t.Run(utest.name, func(t *testing.T) { + err := IsSetValueValid(utest.stringPath, utest.value) + if utest.expectedError == true { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + }) + } +}