diff --git a/app/controllers/machine_configs_controller.rb b/app/controllers/machine_configs_controller.rb index 885aa0f..90d24c2 100644 --- a/app/controllers/machine_configs_controller.rb +++ b/app/controllers/machine_configs_controller.rb @@ -24,6 +24,7 @@ def create :private_ip, :already_configured, :install_disk, + :ephemeral_disk_identifier, ) @machine_config = MachineConfig.new(machine_config_params) @server = @machine_config.server diff --git a/app/models/machine_config.rb b/app/models/machine_config.rb index 3587b65..c115f22 100644 --- a/app/models/machine_config.rb +++ b/app/models/machine_config.rb @@ -23,6 +23,7 @@ def initialize(message, output) validates_presence_of :private_ip validate :validate_private_ip_format validates_presence_of :install_disk + validate :validate_ephemeral_disk_identifier_format after_create :set_configured, if: :already_configured @@ -97,7 +98,38 @@ def generate_config(output_type: server.talos_type) end # NOTE: This also gives us consistent 2 space indentation - talosconfig.to_yaml.delete_prefix("---\n") + config = talosconfig.to_yaml + + # Add a VolumeConfig document if the server has an ephemeral disk identifier + if ephemeral_disk_identifier.present? + # id_type will be "wwid" for regular disks or "uuid" for raid arrays + id_type, id = ephemeral_disk_identifier.split(":", 2) + + # https://www.talos.dev/v1.10/talos-guides/configuration/disk-management/#disk-selector + disk_selector_cel = + if id_type == "wwid" + "disk.wwid == '#{id}'" + elsif id_type == "uuid" + # Talos takes the UUID hex and puts colons every 8 characters + # 1a462672-bd83-888c-df8f-a57e6b38f998 -> 1a462672:bd83888c:df8fa57e:6b38f998 + uuid_talos_style = id.delete("-").chars.each_slice(8).map(&:join).join(":") + "'dev/disk/by-id/md-uuid-#{uuid_talos_style}' in disk.symlinks" + end + + config += <<~YAML + --- + apiVersion: v1alpha1 + kind: VolumeConfig + name: EPHEMERAL + provisioning: + diskSelector: + match: "#{disk_selector_cel}" + minSize: 10GB + grow: true + YAML + end + + config end private @@ -144,6 +176,14 @@ def validate_private_ip_format end end + def validate_ephemeral_disk_identifier_format + return if ephemeral_disk_identifier.blank? + + unless ephemeral_disk_identifier.match?(/^(wwid|uuid):.+$/) + errors.add(:ephemeral_disk_identifier, "must be in the format 'wwid:' or 'uuid:'") + end + end + def set_configured server.update!( last_configured_at: Time.now, diff --git a/app/views/machine_configs/new.html.erb b/app/views/machine_configs/new.html.erb index bd82495..52729c2 100644 --- a/app/views/machine_configs/new.html.erb +++ b/app/views/machine_configs/new.html.erb @@ -5,9 +5,51 @@ <%= f.text_field :hostname, required: true, placeholder: "worker-x", hint: "Will substitute ${hostname} in your config patches." %> <%= f.text_field :private_ip, required: true, placeholder: "10.0.x.x", label: "Private IP", hint: "Will substitute ${private_ip} in your config patches." %> - <%= f.text_field :install_disk, required: true, placeholder: "/dev/sda", hint: "Gets passed into the --install-disk argument for talosctl gen config." %> + <% if f.object.server.lsblk.present? %> + <%= f.text_field :install_disk, required: true, disabled: true, hint: "Gets passed into the --install-disk argument for talosctl gen config. Non modifiable: must use the same disk which was used for bootstrapping." %> + <% + # - RAID devices are listed as children of disks + # - UUID of disks containing RAID devices are actually the UUID of the RAID device + # Hence we reduce RAID disks to a single entry per RAID device, using their UUID as value. + disks = f.object.server.lsblk.fetch("blockdevices").select { |disk| disk.fetch("type") == "disk" } + raids, other = disks.partition { |disk| disk.fetch("children", []).any? { |child| child.fetch("type").start_with?("raid") } } + raids = raids + .group_by { |disk| disk.fetch("children").find { |child| child.fetch("type").start_with?("raid") }&.fetch("name") } + .map do |raid_name, disks| + raid = disks.first.fetch("children").find { |child| child.fetch("type").start_with?("raid") } + type = raid.fetch("type").upcase + label = "/dev/#{raid_name} (#{number_to_human_size(raid.fetch('size'))}) [#{type}]" + value = disks.first.fetch("uuid") + + [label, "uuid:#{value}"] + end + other = other + .reject { |disk| disk.fetch("wwn") == f.object.server.bootstrap_disk_wwid } + .map do |disk| + partitions = disk.fetch("children", []).select { it.fetch("type").start_with?("part") } + suffix = partitions.empty? ? "" : "[#{partitions.length} existing partition#{'s' if partitions.length != 1}]" + label = "/dev/#{disk['name']} (#{number_to_human_size(disk['size'])}) #{suffix}" + value = disk.fetch("wwn") + + [label, "wwid:#{value}"] + end + ephemeral_disk_options = (raids + other).sort_by(&:first) + %> + <%= + f.select( + :ephemeral_disk_identifier, + ephemeral_disk_options, + { include_blank: "Same as install disk" }, + required: false, + label: "Disk for EPHEMERAL partition (optional)", + hint: "If an ephemeral disk is selected, a VolumeConfig will be added to the server's MachineConfig to use the disk for Talos Linux's EPHEMERAL partition. This is the disk mounted at /var which includes local persistent volumes, so the name ephemeral is misleading. RAID devices will be listed here but note that your Talos Factory Image must contain the mdadm extension for them to work.", + ) + %> + <% else %> + <%= f.text_field :install_disk, required: true, placeholder: "/dev/nvme0n1", hint: "Gets passed into the --install-disk argument for talosctl gen config." %> + <% end %> <%= f.select :config_id, Config.pluck(:name, :id), { include_blank: true }, required: true %> <%= f.check_box :already_configured, class: "mr-1", hint: "Check to apply green status to the server immediately" %> <%= f.submit "Configure" %> -<% end %> \ No newline at end of file +<% end %> diff --git a/db/migrate/20250804112912_add_ephemeral_disk_identifier_to_machine_configs.rb b/db/migrate/20250804112912_add_ephemeral_disk_identifier_to_machine_configs.rb new file mode 100644 index 0000000..4f0e42c --- /dev/null +++ b/db/migrate/20250804112912_add_ephemeral_disk_identifier_to_machine_configs.rb @@ -0,0 +1,5 @@ +class AddEphemeralDiskIdentifierToMachineConfigs < ActiveRecord::Migration[8.0] + def change + add_column :machine_configs, :ephemeral_disk_identifier, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 744b644..e30db15 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_28_135005) do +ActiveRecord::Schema[8.0].define(version: 2025_08_04_112912) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -69,6 +69,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "install_disk", default: "/dev/sda", null: false + t.string "ephemeral_disk_identifier" t.index ["config_id"], name: "index_machine_configs_on_config_id" t.index ["server_id"], name: "index_machine_configs_on_server_id" end diff --git a/spec/fixtures/clusters.yml b/spec/fixtures/clusters.yml index 75d466a..a22503b 100644 --- a/spec/fixtures/clusters.yml +++ b/spec/fixtures/clusters.yml @@ -1,6 +1,6 @@ default: name: default - endpoint: http://kubernetes.example.com:6443 + endpoint: https://kubernetes.example.com:6443 secrets: | cluster: id: d4ztP13Cr27K_0eTNQmM0I1_k50EHxci7bT_KROwxrI= diff --git a/spec/models/machine_config_spec.rb b/spec/models/machine_config_spec.rb index a793f7b..b64c4b4 100644 --- a/spec/models/machine_config_spec.rb +++ b/spec/models/machine_config_spec.rb @@ -1,4 +1,22 @@ RSpec.describe MachineConfig do + it "validates format of ephemeral_disk_identifier" do + machine_config = MachineConfig.new + machine_config.validate + expect(machine_config.errors[:ephemeral_disk_identifier]).to be_empty + + machine_config.ephemeral_disk_identifier = "wwid:1234567890abcdef" + machine_config.validate + expect(machine_config.errors[:ephemeral_disk_identifier]).to be_empty + + machine_config.ephemeral_disk_identifier = "uuid:12345678-1234-5678-1234-567812345678" + machine_config.validate + expect(machine_config.errors[:ephemeral_disk_identifier]).to be_empty + + machine_config.ephemeral_disk_identifier = "invalid_format" + machine_config.validate + expect(machine_config.errors[:ephemeral_disk_identifier]).to include("must be in the format 'wwid:' or 'uuid:'") + end + describe "#generate_config" do it "raises an error if hostname is blank" do server = Server.new(name: "worker-1") @@ -12,7 +30,7 @@ expect { machine_config.generate_config }.to raise_error "can't generate config before assigning private_ip" end - it "generates a config" do + it "generates a config including substitution variables: bootstrap_disk_wwid, hostname, public_ip, private_ip and vlan" do hetzner_vswitch = HetznerVswitch.new(name: "vswitch", vlan: 1337) cluster = Cluster.create!( name: "cluster", @@ -69,6 +87,7 @@ server:, ) expect(machine_config.generate_config).to eq <<~YAML + --- version: v1alpha1 debug: false persist: true @@ -147,5 +166,59 @@ service: {} YAML end + + context "with an ephemeral_disk_identifier" do + it "generates a config including a VolumeConfig for the ephemeral disk with different matchers for uuid and wwid" do + server = servers(:cloud_botstrapped) + + config = Config.new( + name: "config", + install_image: "ghcr.io/siderolabs/installer:v1.10.6", + kubernetes_version: "1.33.3", + kubespan: true, + patch: "", + ) + machine_config = MachineConfig.new( + hostname: server.name, + private_ip: "10.0.1.2", + install_disk: "/dev/nvme0n1", + ephemeral_disk_identifier: "wwid:eui.36344630528029720025384500000002", + config:, + server:, + ) + + generated_config = machine_config.generate_config + expect(YAML.load_stream(generated_config).length).to eq 2 # sanity check that YAML is valid + + volume_config = generated_config.split("---\n").last # expect on raw String to ensure good formatting + expect(volume_config).to eq <<~YAML + apiVersion: v1alpha1 + kind: VolumeConfig + name: EPHEMERAL + provisioning: + diskSelector: + match: "disk.wwid == 'eui.36344630528029720025384500000002'" + minSize: 10GB + grow: true + YAML + + machine_config.ephemeral_disk_identifier = "uuid:1a462672-bd83-888c-df8f-a57e6b38f998" + + generated_config = machine_config.generate_config + expect(YAML.load_stream(generated_config).length).to eq 2 # sanity check that YAML is valid + + volume_config = generated_config.split("---\n").last # expect on raw String to ensure good formatting + expect(volume_config).to eq <<~YAML + apiVersion: v1alpha1 + kind: VolumeConfig + name: EPHEMERAL + provisioning: + diskSelector: + match: "'dev/disk/by-id/md-uuid-1a462672:bd83888c:df8fa57e:6b38f998' in disk.symlinks" + minSize: 10GB + grow: true + YAML + end + end end end