diff --git a/api/v1beta1/types.go b/api/v1beta1/types.go index 21ecb3111..c1df0985c 100644 --- a/api/v1beta1/types.go +++ b/api/v1beta1/types.go @@ -26,6 +26,8 @@ type Install struct { // +optional Device string `json:"device,omitempty" yaml:"device,omitempty"` // +optional + DeviceSelector DeviceSelector `json:"device-selector,omitempty" yaml:"device-selector,omitempty"` + // +optional NoFormat bool `json:"no-format,omitempty" yaml:"no-format,omitempty"` // +optional ConfigURLs []string `json:"config-urls,omitempty" yaml:"config-urls,omitempty"` @@ -120,3 +122,31 @@ type Config struct { // +optional CloudConfig map[string]runtime.RawExtension `json:"cloud-config,omitempty" yaml:"cloud-config,omitempty"` } + +type DeviceSelector []DeviceSelectorRequirement + +type DeviceSelectorRequirement struct { + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=Name;Size + Key DeviceSelectorKey `json:"key"` + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=In;NotIn;Gt;Lt + Operator DeviceSelectorOperator `json:"operator"` + // +optional + Values []string `json:"values,omitempty"` +} + +type DeviceSelectorKey string +type DeviceSelectorOperator string + +const ( + DeviceSelectorOpIn DeviceSelectorOperator = "In" + DeviceSelectorOpNotIn DeviceSelectorOperator = "NotIn" + DeviceSelectorOpGt DeviceSelectorOperator = "Gt" + DeviceSelectorOpLt DeviceSelectorOperator = "Lt" + + DeviceSelectorKeyName DeviceSelectorKey = "Name" + DeviceSelectorKeySize DeviceSelectorKey = "Size" +) diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 841bb8e2a..1774df654 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -120,6 +120,47 @@ func (in *ContainerImage) DeepCopy() *ContainerImage { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in DeviceSelector) DeepCopyInto(out *DeviceSelector) { + { + in := &in + *out = make(DeviceSelector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeviceSelector. +func (in DeviceSelector) DeepCopy() DeviceSelector { + if in == nil { + return nil + } + out := new(DeviceSelector) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeviceSelectorRequirement) DeepCopyInto(out *DeviceSelectorRequirement) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeviceSelectorRequirement. +func (in *DeviceSelectorRequirement) DeepCopy() *DeviceSelectorRequirement { + if in == nil { + return nil + } + out := new(DeviceSelectorRequirement) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Elemental) DeepCopyInto(out *Elemental) { *out = *in @@ -173,6 +214,13 @@ func (in *ImageCommons) DeepCopy() *ImageCommons { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Install) DeepCopyInto(out *Install) { *out = *in + if in.DeviceSelector != nil { + in, out := &in.DeviceSelector, &out.DeviceSelector + *out = make(DeviceSelector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.ConfigURLs != nil { in, out := &in.ConfigURLs, &out.ConfigURLs *out = make([]string, len(*in)) diff --git a/charts/crds/templates/crds.yaml b/charts/crds/templates/crds.yaml index e577bc06a..dd5e56c1f 100644 --- a/charts/crds/templates/crds.yaml +++ b/charts/crds/templates/crds.yaml @@ -669,6 +669,30 @@ spec: type: boolean device: type: string + device-selector: + items: + properties: + key: + enum: + - Name + - Size + type: string + operator: + enum: + - In + - NotIn + - Gt + - Lt + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array disable-boot-entry: type: boolean eject-cd: diff --git a/cmd/register/main.go b/cmd/register/main.go index 59992d576..42d03b83c 100644 --- a/cmd/register/main.go +++ b/cmd/register/main.go @@ -21,6 +21,7 @@ import ( "fmt" "time" + "github.com/jaypipes/ghw" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/twpayne/go-vfs" @@ -57,7 +58,12 @@ var ( func main() { fs := vfs.OSFS - installer := install.NewInstaller(fs) + blockInfo, err := ghw.Block(ghw.WithDisableWarnings()) + if err != nil { + log.Warningf("error probing disks: %s", err) + } + + installer := install.NewInstaller(fs, blockInfo.Disks) stateHandler := register.NewFileStateHandler(fs) client := register.NewClient() cmd := newCommand(fs, client, stateHandler, installer) diff --git a/config/crd/bases/elemental.cattle.io_machineregistrations.yaml b/config/crd/bases/elemental.cattle.io_machineregistrations.yaml index 0896bccc6..75c0f1787 100644 --- a/config/crd/bases/elemental.cattle.io_machineregistrations.yaml +++ b/config/crd/bases/elemental.cattle.io_machineregistrations.yaml @@ -53,6 +53,30 @@ spec: type: boolean device: type: string + device-selector: + items: + properties: + key: + enum: + - Name + - Size + type: string + operator: + enum: + - In + - NotIn + - Gt + - Lt + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array disable-boot-entry: type: boolean eject-cd: diff --git a/pkg/install/install.go b/pkg/install/install.go index 34e68236d..f61d631e5 100644 --- a/pkg/install/install.go +++ b/pkg/install/install.go @@ -22,19 +22,23 @@ import ( "os" "path/filepath" + "github.com/jaypipes/ghw" + "github.com/jaypipes/ghw/pkg/block" "github.com/mudler/yip/pkg/schema" - elementalv1 "github.com/rancher/elemental-operator/api/v1beta1" - "github.com/rancher/elemental-operator/controllers" - "github.com/rancher/elemental-operator/pkg/elementalcli" - "github.com/rancher/elemental-operator/pkg/log" - "github.com/rancher/elemental-operator/pkg/register" - "github.com/rancher/elemental-operator/pkg/util" agent "github.com/rancher/system-agent/pkg/config" "github.com/twpayne/go-vfs" "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" + + elementalv1 "github.com/rancher/elemental-operator/api/v1beta1" + "github.com/rancher/elemental-operator/controllers" + "github.com/rancher/elemental-operator/pkg/elementalcli" + "github.com/rancher/elemental-operator/pkg/log" + "github.com/rancher/elemental-operator/pkg/register" + "github.com/rancher/elemental-operator/pkg/util" ) const ( @@ -59,9 +63,10 @@ type Installer interface { WriteLocalSystemAgentConfig(config elementalv1.Elemental) error } -func NewInstaller(fs vfs.FS) Installer { +func NewInstaller(fs vfs.FS, disks []*block.Disk) Installer { return &installer{ fs: fs, + disks: disks, runner: elementalcli.NewRunner(), } } @@ -70,6 +75,7 @@ var _ Installer = (*installer)(nil) type installer struct { fs vfs.FS + disks []*block.Disk runner elementalcli.Runner } @@ -78,6 +84,15 @@ func (i *installer) InstallElemental(config elementalv1.Config, state register.S config.Elemental.Install.ConfigURLs = []string{} } + if config.Elemental.Install.Device == "" { + deviceName, err := i.findInstallationDevice(config.Elemental.Install.DeviceSelector) + if err != nil { + return fmt.Errorf("failed picking installation device: %w", err) + } + + config.Elemental.Install.Device = deviceName + } + additionalConfigs, err := i.getCloudInitConfigs(config, state) if err != nil { return fmt.Errorf("generating additional cloud configs: %w", err) @@ -116,6 +131,99 @@ func (i *installer) ResetElemental(config elementalv1.Config, state register.Sta return nil } +func (i *installer) findInstallationDevice(selector elementalv1.DeviceSelector) (string, error) { + devices := map[string]*ghw.Disk{} + + for _, disk := range i.disks { + devices[disk.Name] = disk + } + + for _, disk := range i.disks { + for _, sel := range selector { + matches, err := matches(disk, sel) + if err != nil { + return "", err + } + + if !matches { + log.Debug("%s does not match selector %s", disk.Name, sel.Key) + delete(devices, disk.Name) + break + } + } + } + + log.Debug("%s disks matching selector", len(devices)) + + for _, dev := range devices { + return dev.Name, nil + } + + return "", fmt.Errorf("no device found matching selector") +} + +func matches(disk *block.Disk, req elementalv1.DeviceSelectorRequirement) (bool, error) { + switch req.Operator { + case elementalv1.DeviceSelectorOpIn: + return matchesIn(disk, req) + case elementalv1.DeviceSelectorOpNotIn: + return matchesNotIn(disk, req) + case elementalv1.DeviceSelectorOpLt: + return matchesLt(disk, req) + case elementalv1.DeviceSelectorOpGt: + return matchesGt(disk, req) + default: + return false, fmt.Errorf("unknown operator: %s", req.Operator) + } +} + +func matchesIn(disk *block.Disk, req elementalv1.DeviceSelectorRequirement) (bool, error) { + if req.Key != elementalv1.DeviceSelectorKeyName { + return false, fmt.Errorf("cannot use In operator on numerical values %s", req.Key) + } + + for _, val := range req.Values { + if val == disk.Name { + return true, nil + } + } + + return false, nil +} +func matchesNotIn(disk *block.Disk, req elementalv1.DeviceSelectorRequirement) (bool, error) { + matches, err := matchesIn(disk, req) + return !matches, err +} +func matchesLt(disk *block.Disk, req elementalv1.DeviceSelectorRequirement) (bool, error) { + if req.Key != elementalv1.DeviceSelectorKeySize { + return false, fmt.Errorf("cannot use Lt operator on string values %s", req.Key) + + } + + keySize, err := resource.ParseQuantity(req.Values[0]) + if err != nil { + return false, fmt.Errorf("failed to parse quantity %s", req.Values[0]) + } + + diskSize := resource.NewQuantity(int64(disk.SizeBytes), resource.BinarySI) + + return diskSize.Cmp(keySize) == -1, nil +} +func matchesGt(disk *block.Disk, req elementalv1.DeviceSelectorRequirement) (bool, error) { + if req.Key != elementalv1.DeviceSelectorKeySize { + return false, fmt.Errorf("cannot use Gt operator on string values %s", req.Key) + } + + keySize, err := resource.ParseQuantity(req.Values[0]) + if err != nil { + return false, fmt.Errorf("failed to parse quantity %s", req.Values[0]) + } + + diskSize := resource.NewQuantity(int64(disk.SizeBytes), resource.BinarySI) + + return diskSize.Cmp(keySize) == 1, nil +} + // getCloudInitConfigs creates cloud-init configuration files that can be passed as additional `config-urls` // to the `elemental` cli. We exploit this mechanism to persist information during `elemental install` // or `elemental reset` calls into the newly installed or resetted system. diff --git a/pkg/install/install_test.go b/pkg/install/install_test.go index 6b3a9b7c3..35798956d 100644 --- a/pkg/install/install_test.go +++ b/pkg/install/install_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/jaypipes/ghw" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/twpayne/go-vfs" @@ -122,6 +123,129 @@ var _ = Describe("installer install elemental", Label("installer", "install"), f }) }) +var _ = Describe("installer pick device", Label("installer", "install", "device", "disk"), func() { + var fs *vfst.TestFS + var err error + var fsCleanup func() + var cliRunner *climocks.MockRunner + var install *installer + BeforeEach(func() { + fs, fsCleanup, err = vfst.NewTestFS(map[string]interface{}{"/tmp/init": ""}) + Expect(err).ToNot(HaveOccurred()) + mockCtrl := gomock.NewController(GinkgoT()) + cliRunner = climocks.NewMockRunner(mockCtrl) + DeferCleanup(fsCleanup) + }) + It("should pick single device no selectors", func() { + install = &installer{ + fs: fs, + runner: cliRunner, + disks: []*ghw.Disk{{Name: "/dev/pickme"}}, + } + actualDevice, err := install.findInstallationDevice(elementalv1.DeviceSelector{}) + Expect(err).ToNot(HaveOccurred()) + Expect(actualDevice).To(Equal("/dev/pickme")) + }) + It("should pick device based on selector name", func() { + install = &installer{ + fs: fs, + runner: cliRunner, + disks: []*ghw.Disk{ + {Name: "/dev/sda"}, + {Name: "/dev/sdb"}, + {Name: "/dev/sdc"}, + {Name: "/dev/sdd"}, + {Name: "/dev/sde"}, + {Name: "/dev/sdf"}, + {Name: "/dev/sdg"}, + }, + } + selector := elementalv1.DeviceSelector{ + { + Key: elementalv1.DeviceSelectorKeyName, + Operator: elementalv1.DeviceSelectorOpIn, + Values: []string{"/dev/sdd"}, + }, + } + + actualDevice, err := install.findInstallationDevice(selector) + Expect(err).ToNot(HaveOccurred()) + Expect(actualDevice).To(Equal("/dev/sdd")) + }) + It("should pick device less than 100Gi", func() { + install = &installer{ + fs: fs, + runner: cliRunner, + disks: []*ghw.Disk{ + {Name: "/dev/sda", SizeBytes: 85899345920}, + {Name: "/dev/sdb", SizeBytes: 214748364800}, + }, + } + selector := elementalv1.DeviceSelector{ + { + Key: elementalv1.DeviceSelectorKeySize, + Operator: elementalv1.DeviceSelectorOpLt, + Values: []string{"100Gi"}, + }, + } + + actualDevice, err := install.findInstallationDevice(selector) + Expect(err).ToNot(HaveOccurred()) + Expect(actualDevice).To(Equal("/dev/sda")) + }) + It("should pick device greater than 100Gi", func() { + install = &installer{ + fs: fs, + runner: cliRunner, + disks: []*ghw.Disk{ + {Name: "/dev/sda", SizeBytes: 85899345920}, + {Name: "/dev/sdb", SizeBytes: 214748364800}, + }, + } + selector := elementalv1.DeviceSelector{ + { + Key: elementalv1.DeviceSelectorKeySize, + Operator: elementalv1.DeviceSelectorOpGt, + Values: []string{"100Gi"}, + }, + } + + actualDevice, err := install.findInstallationDevice(selector) + Expect(err).ToNot(HaveOccurred()) + Expect(actualDevice).To(Equal("/dev/sdb")) + }) + It("should not error out for 2 matching devices", func() { + install = &installer{ + fs: fs, + runner: cliRunner, + disks: []*ghw.Disk{ + {Name: "/dev/sda"}, + {Name: "/dev/sdb"}, + }, + } + selector := elementalv1.DeviceSelector{ + { + Key: elementalv1.DeviceSelectorKeyName, + Operator: elementalv1.DeviceSelectorOpIn, + Values: []string{"/dev/sda", "/dev/sdb"}, + }, + } + actualDevice, err := install.findInstallationDevice(selector) + Expect(err).ToNot(HaveOccurred()) + Expect(actualDevice).ToNot(BeEmpty()) + }) + It("should error out for no devices", func() { + install = &installer{ + fs: fs, + runner: cliRunner, + disks: []*ghw.Disk{}, + } + actualDevice, err := install.findInstallationDevice(elementalv1.DeviceSelector{}) + Expect(err).To(HaveOccurred()) + Expect(actualDevice).To(BeEmpty()) + }) +}) + var _ = Describe("installer reset elemental", Label("installer", "reset"), func() { var fs *vfst.TestFS var err error