From 81ae8157ad78bbe3899afa03b3125493242db478 Mon Sep 17 00:00:00 2001 From: Michal Opala Date: Mon, 4 Dec 2023 15:08:40 +0100 Subject: [PATCH] F OpenNebula/one#6280: Add the VRouter service This is a refactor of the previously used VNF service (to be obsolete). All the main features have been included: - DHCP4 server (kea + onelease) - DNS forwarder (unbound) - Router4 - NAT4 - SDNAT4 - LVS (keepalived's built-in, static + onegate) - HAProxy (static + onegate) - Keepalived with failover capability (via one-failover service) Also works: - It's *mostly* compatible with the VNF's interface. - Each feature is presented as a simple OpenRC service. - Failover is now implemented via unix pipes instead of script hooks. - Load-balancers (LVS and HAProxy) work now in both VROUTER and OneFlow modes. - Re-contextualization is fully supported. - Lots of unit tests (rspec). --- Makefile | 3 + Makefile.config | 2 +- .../DHCP4/kea-hook-onelease4-1.1.1-r0.apk | Bin 0 -> 32378 bytes appliances/VRouter/DHCP4/main.rb | 180 ++++ appliances/VRouter/DHCP4/tests.rb | 105 ++ appliances/VRouter/DNS/main.rb | 343 +++++++ appliances/VRouter/DNS/tests.rb | 114 +++ appliances/VRouter/Failover/execute.rb | 192 ++++ appliances/VRouter/Failover/main.rb | 71 ++ appliances/VRouter/HAProxy/execute.rb | 80 ++ appliances/VRouter/HAProxy/main.rb | 111 +++ appliances/VRouter/HAProxy/tests.rb | 379 +++++++ appliances/VRouter/Keepalived/main.rb | 129 +++ appliances/VRouter/Keepalived/tests.rb | 252 +++++ appliances/VRouter/LVS/execute.rb | 104 ++ appliances/VRouter/LVS/main.rb | 82 ++ appliances/VRouter/LVS/tests.rb | 514 ++++++++++ appliances/VRouter/NAT4/main.rb | 121 +++ appliances/VRouter/Router4/main.rb | 122 +++ appliances/VRouter/Router4/tests.rb | 94 ++ appliances/VRouter/SDNAT4/execute.rb | 142 +++ appliances/VRouter/SDNAT4/main.rb | 108 ++ appliances/VRouter/SDNAT4/tests.rb | 180 ++++ appliances/VRouter/tests.rb | 923 ++++++++++++++++++ appliances/VRouter/tests.sh | 7 + appliances/VRouter/vrouter.rb | 399 ++++++++ appliances/lib/helpers.rb | 219 +++++ appliances/lib/tests.rb | 179 ++++ appliances/lib/tests.sh | 7 + appliances/scripts/net-90 | 6 + appliances/scripts/net-99 | 48 + appliances/service | 84 ++ packer/service_VRouter/10-update.sh | 16 + packer/service_VRouter/81-configure-ssh.sh | 44 + .../service_VRouter/82-configure-context.sh | 19 + packer/service_VRouter/VRouter.pkr.hcl | 103 ++ packer/service_VRouter/gen_context | 30 + packer/service_VRouter/variables.pkr.hcl | 22 + 38 files changed, 5533 insertions(+), 1 deletion(-) create mode 100644 appliances/VRouter/DHCP4/kea-hook-onelease4-1.1.1-r0.apk create mode 100644 appliances/VRouter/DHCP4/main.rb create mode 100644 appliances/VRouter/DHCP4/tests.rb create mode 100644 appliances/VRouter/DNS/main.rb create mode 100644 appliances/VRouter/DNS/tests.rb create mode 100644 appliances/VRouter/Failover/execute.rb create mode 100644 appliances/VRouter/Failover/main.rb create mode 100644 appliances/VRouter/HAProxy/execute.rb create mode 100644 appliances/VRouter/HAProxy/main.rb create mode 100644 appliances/VRouter/HAProxy/tests.rb create mode 100644 appliances/VRouter/Keepalived/main.rb create mode 100644 appliances/VRouter/Keepalived/tests.rb create mode 100644 appliances/VRouter/LVS/execute.rb create mode 100644 appliances/VRouter/LVS/main.rb create mode 100644 appliances/VRouter/LVS/tests.rb create mode 100644 appliances/VRouter/NAT4/main.rb create mode 100644 appliances/VRouter/Router4/main.rb create mode 100644 appliances/VRouter/Router4/tests.rb create mode 100644 appliances/VRouter/SDNAT4/execute.rb create mode 100644 appliances/VRouter/SDNAT4/main.rb create mode 100644 appliances/VRouter/SDNAT4/tests.rb create mode 100644 appliances/VRouter/tests.rb create mode 100755 appliances/VRouter/tests.sh create mode 100644 appliances/VRouter/vrouter.rb create mode 100644 appliances/lib/helpers.rb create mode 100644 appliances/lib/tests.rb create mode 100755 appliances/lib/tests.sh create mode 100755 appliances/scripts/net-90 create mode 100755 appliances/scripts/net-99 create mode 100755 appliances/service create mode 100644 packer/service_VRouter/10-update.sh create mode 100644 packer/service_VRouter/81-configure-ssh.sh create mode 100644 packer/service_VRouter/82-configure-context.sh create mode 100644 packer/service_VRouter/VRouter.pkr.hcl create mode 100755 packer/service_VRouter/gen_context create mode 100644 packer/service_VRouter/variables.pkr.hcl diff --git a/Makefile b/Makefile index 415c846f..b089d397 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,9 @@ packer-service_vnf: packer-alpine318 ${DIR_EXPORT}/service_vnf.qcow2 packer-service_wordpress: packer-alma8 ${DIR_EXPORT}/service_wordpress.qcow2 @${INFO} "Packer service_wordpress done" +packer-service_VRouter: packer-alpine318 ${DIR_EXPORT}/service_VRouter.qcow2 + @${INFO} "Packer service_VRouter done" + packer-service_OneKE: packer-ubuntu2204 ${DIR_EXPORT}/service_OneKE.qcow2 @${INFO} "Packer service_OneKE done" diff --git a/Makefile.config b/Makefile.config index 5f34a413..4b45a0cf 100644 --- a/Makefile.config +++ b/Makefile.config @@ -21,7 +21,7 @@ DISTROS := alma8 alma9 \ rocky8 rocky9 \ ubuntu2004 ubuntu2004min ubuntu2204 ubuntu2204min -SERVICES := service_vnf service_wordpress service_OneKE +SERVICES := service_vnf service_wordpress service_VRouter service_OneKE .DEFAULT_GOAL := help diff --git a/appliances/VRouter/DHCP4/kea-hook-onelease4-1.1.1-r0.apk b/appliances/VRouter/DHCP4/kea-hook-onelease4-1.1.1-r0.apk new file mode 100644 index 0000000000000000000000000000000000000000..c946778e3a93c5a521db5ad44cbf3d9c35bef63c GIT binary patch literal 32378 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 literal 0 HcmV?d00001 diff --git a/appliances/VRouter/DHCP4/main.rb b/appliances/VRouter/DHCP4/main.rb new file mode 100644 index 00000000..46989d7c --- /dev/null +++ b/appliances/VRouter/DHCP4/main.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require_relative '../vrouter.rb' + +module Service +module DHCP4 + extend self + + DEPENDS_ON = %w[Service::Failover] + + ONEAPP_VNF_DHCP4_ENABLED = env :ONEAPP_VNF_DHCP4_ENABLED, 'NO' + + ONEAPP_VNF_DHCP4_AUTHORITATIVE = env :ONEAPP_VNF_DHCP4_AUTHORITATIVE, 'YES' + + ONEAPP_VNF_DHCP4_MAC2IP_ENABLED = env :ONEAPP_VNF_DHCP4_MAC2IP_ENABLED, 'YES' + ONEAPP_VNF_DHCP4_MAC2IP_MACPREFIX = env :ONEAPP_VNF_DHCP4_MAC2IP_MACPREFIX, '02:00' + + ONEAPP_VNF_DHCP4_LEASE_TIME = env :ONEAPP_VNF_DHCP4_LEASE_TIME, '3600' + + ONEAPP_VNF_DHCP4_GATEWAY = env :ONEAPP_VNF_DHCP4_GATEWAY, nil + ONEAPP_VNF_DHCP4_DNS = env :ONEAPP_VNF_DHCP4_DNS, nil + + ONEAPP_VNF_DHCP4_INTERFACES = env :ONEAPP_VNF_DHCP4_INTERFACES, '' # nil -> none, empty -> all + + attr_reader :interfaces, :mgmt + + @interfaces = parse_interfaces ONEAPP_VNF_DHCP4_INTERFACES + @mgmt = detect_mgmt_interfaces + + def parse_env + interfaces = @interfaces.keys - @mgmt + + n2a = addrs_to_nics(interfaces, family: %w[inet]).to_h do |a, n| + [n.first, a] + end + + a2s = addrs_to_subnets(interfaces, family: %w[inet]).to_h do |a, s| + [a.split('/').first, s] + end + + s2r = subnets_to_ranges(a2s.values) + + interfaces.each_with_object({}) do |nic, vars| + p = env("ONEAPP_VNF_DHCP4_#{nic.upcase}", nil)&.split(':')&.map(&:strip) + + vars[nic] = { + address: n2a[nic], + subnet: if p.nil? then a2s[n2a[nic]] else p[0] end, + range: if p.nil? then s2r[a2s[n2a[nic]]] else p[1] end, + gateway: env("ONEAPP_VNF_DHCP4_#{nic.upcase}_GATEWAY", ONEAPP_VNF_DHCP4_GATEWAY), + dns: env("ONEAPP_VNF_DHCP4_#{nic.upcase}_DNS", ONEAPP_VNF_DHCP4_DNS), + mtu: env("ONEAPP_VNF_DHCP4_#{nic.upcase}_MTU", ip_link_show(nic)['mtu']), + } + end + end + + def install(initdir: '/etc/init.d') + msg :info, 'DHCP4::install' + + onelease4_apk = File.join File.dirname(__FILE__), 'kea-hook-onelease4-1.1.1-r0.apk' + + puts bash <<~SCRIPT + apk --no-cache add ruby kea-dhcp4 + apk --no-cache --allow-untrusted add '#{onelease4_apk}' + SCRIPT + + file "#{initdir}/one-dhcp4", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__}" + + output_log="/var/log/one-appliance/one-dhcp4.log" + error_log="/var/log/one-appliance/one-dhcp4.log" + + depend() { + after net firewall keepalived + } + + start_pre() { + rc-service kea-dhcp4 start --nodeps + } + + start() { :; } + + stop() { :; } + + stop_post() { + rc-service kea-dhcp4 stop --nodeps + } + SERVICE + + toggle [:update] + end + + def configure(basedir: '/etc/kea') + msg :info, 'DHCP4::configure' + + if ONEAPP_VNF_DHCP4_ENABLED + dhcp4_vars = parse_env + + config = { 'Dhcp4' => { + 'interfaces-config' => { 'interfaces' => dhcp4_vars.keys }, + 'authoritative' => ONEAPP_VNF_DHCP4_AUTHORITATIVE, + 'option-data' => [], + 'subnet4' => dhcp4_vars.map do |nic, vars| + data = [] + data << { 'name' => 'routers', 'data' => vars[:gateway] } unless vars[:gateway].nil? + data << { 'name' => 'domain-name-servers', 'data' => vars[:dns] } unless vars[:dns].nil? + data << { 'name' => 'interface-mtu', 'data' => vars[:mtu].to_s } unless vars[:mtu].nil? || nic == 'lo' + { 'subnet' => vars[:subnet], + 'pools' => [ { 'pool' => vars[:range] } ], + 'option-data' => data, + 'reservations' => [ + { 'flex-id' => "'DO-NOT-LEASE-#{vars[:address]}'", + 'ip-address' => vars[:address] } ], + 'reservation-mode' => 'all' } + end, + 'lease-database' => { + 'type' => 'memfile', + 'persist' => true, + 'lfc-interval' => 2 * ONEAPP_VNF_DHCP4_LEASE_TIME.to_i + }, + 'sanity-checks' => { 'lease-checks' => 'fix-del' }, + 'valid-lifetime' => ONEAPP_VNF_DHCP4_LEASE_TIME.to_i, + 'calculate-tee-times' => true, + 'loggers' => [ + { 'name' => 'kea-dhcp4', + 'output_options' => [ { 'output' => '/var/log/kea/kea-dhcp4.log' } ], + 'severity' => 'INFO', + 'debuglevel' => 0 } + ], + 'hooks-libraries' => if ONEAPP_VNF_DHCP4_MAC2IP_ENABLED then + [ { 'library' => '/usr/lib/kea/hooks/libkea-onelease-dhcp4.so', + 'parameters' => { + 'enabled' => true, + 'byte-prefix' => ONEAPP_VNF_DHCP4_MAC2IP_MACPREFIX, + 'logger-name' => 'onelease-dhcp4', + 'debug' => false, + 'debug-logfile' => '/var/log/kea/onelease-dhcp4-debug.log' } } ] + else [] end + } } + + file "#{basedir}/kea-dhcp4.conf", JSON.pretty_generate(config), owner: 'kea', + group: 'kea', + mode: 'u=rw,g=r,o=', + overwrite: true + toggle [:enable] + else + toggle [:stop, :disable] + end + end + + def toggle(operations) + operations.each do |op| + msg :debug, "DHCP4::toggle([:#{op}])" + case op + when :reload + puts bash 'rc-service kea-dhcp4 reload' + when :enable + puts bash 'rc-update add kea-dhcp4 default' + puts bash 'rc-update add one-dhcp4 default' + when :disable + puts bash 'rc-update del kea-dhcp4 default ||:' + puts bash 'rc-update del one-dhcp4 default ||:' + when :update + puts bash 'rc-update -u' + else + puts bash "rc-service one-dhcp4 #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'DHCP4::bootstrap' + end +end +end diff --git a/appliances/VRouter/DHCP4/tests.rb b/appliances/VRouter/DHCP4/tests.rb new file mode 100644 index 00000000..299042bb --- /dev/null +++ b/appliances/VRouter/DHCP4/tests.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'rspec' + +def clear_env + ENV.delete_if { |name| name.include?('VROUTER_') || name.include?('_VNF_') } +end + +RSpec.describe self do + it 'should provide and parse all env vars' do + clear_env + + ENV['ONEAPP_VNF_DHCP4_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_DHCP4_AUTHORITATIVE'] = 'YES' + + ENV['ONEAPP_VNF_DHCP4_MAC2IP_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_DHCP4_MAC2IP_MACPREFIX'] = '02:00' + + ENV['ONEAPP_VNF_DHCP4_LEASE_TIME'] = '3600' + + ENV['ONEAPP_VNF_DHCP4_GATEWAY'] = '1.2.3.4' + ENV['ONEAPP_VNF_DHCP4_DNS'] = '1.1.1.1' + + ENV['ONEAPP_VNF_DHCP4_INTERFACES'] = 'lo/127.0.0.1 eth0 eth1 eth2 eth3' + ENV['ETH0_VROUTER_MANAGEMENT'] = 'YES' + + ENV['ONEAPP_VNF_DHCP4_ETH2'] = '30.0.0.0/8:30.40.50.64-30.40.50.68' + ENV['ONEAPP_VNF_DHCP4_ETH2_GATEWAY'] = '30.40.50.1' + ENV['ONEAPP_VNF_DHCP4_ETH2_DNS'] = '8.8.8.8' + + ENV['ONEAPP_VNF_DHCP4_ETH3_GATEWAY'] = '40.50.60.1' + ENV['ONEAPP_VNF_DHCP4_ETH3_DNS'] = '8.8.4.4' + + load './main.rb'; include Service::DHCP4 + + allow(Service::DHCP4).to receive(:ip_addr_list).and_return([ + { 'ifname' => 'lo', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '127.0.0.1', + 'prefixlen' => 8 } ] }, + + { 'ifname' => 'eth0', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '10.20.30.40', + 'prefixlen' => 24 } ] }, + + { 'ifname' => 'eth1', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '20.30.40.50', + 'prefixlen' => 16 } ] }, + + { 'ifname' => 'eth2', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '30.40.50.60', + 'prefixlen' => 8 } ] }, + + { 'ifname' => 'eth3', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '40.50.60.70', + 'prefixlen' => 24 } ] }, + ]) + + allow(Service::DHCP4).to receive(:ip_link_show).and_return( + { 'mtu' => 1111 }, + { 'mtu' => 2222 }, + { 'mtu' => 3333 }, + { 'mtu' => 4444 }, + ) + + expect(Service::DHCP4.parse_env).to eq ({ + 'lo' => { + address: '127.0.0.1', + dns: '1.1.1.1', + gateway: '1.2.3.4', + mtu: 1111, + range: '127.0.0.2-127.255.255.254', + subnet: '127.0.0.0/8', + }, + 'eth1' => { + address: '20.30.40.50', + dns: '1.1.1.1', + gateway: '1.2.3.4', + mtu: 2222, + range: '20.30.0.2-20.30.255.254', + subnet: '20.30.0.0/16', + }, + 'eth2' => { + address: '30.40.50.60', + dns: '8.8.8.8', + gateway: '30.40.50.1', + mtu: 3333, + range: '30.40.50.64-30.40.50.68', + subnet: '30.0.0.0/8', + }, + 'eth3' => { + address: '40.50.60.70', + dns: '8.8.4.4', + gateway: '40.50.60.1', + mtu: 4444, + range: '40.50.60.2-40.50.60.254', + subnet: '40.50.60.0/24', + }, + }) + end +end diff --git a/appliances/VRouter/DNS/main.rb b/appliances/VRouter/DNS/main.rb new file mode 100644 index 00000000..afaa8c6f --- /dev/null +++ b/appliances/VRouter/DNS/main.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' + +module Service +module DNS + extend self + + DEPENDS_ON = %w[Service::Failover] + + ONEAPP_VNF_DNS_ENABLED = env :ONEAPP_VNF_DNS_ENABLED, 'NO' + ONEAPP_VNF_DNS_TCP_DISABLED = env :ONEAPP_VNF_DNS_TCP_DISABLED, 'NO' + ONEAPP_VNF_DNS_UDP_DISABLED = env :ONEAPP_VNF_DNS_UDP_DISABLED, 'NO' + + ONEAPP_VNF_DNS_UPSTREAM_TIMEOUT = env :ONEAPP_VNF_DNS_UPSTREAM_TIMEOUT, 1128 + ONEAPP_VNF_DNS_MAX_CACHE_TTL = env :ONEAPP_VNF_DNS_MAX_CACHE_TTL, 3600 + + ONEAPP_VNF_DNS_USE_ROOTSERVERS = env :ONEAPP_VNF_DNS_USE_ROOTSERVERS, 'YES' + ONEAPP_VNF_DNS_NAMESERVERS = env :ONEAPP_VNF_DNS_NAMESERVERS, '' + + ONEAPP_VNF_DNS_INTERFACES = env :ONEAPP_VNF_DNS_INTERFACES, '' # nil -> none, empty -> all + ONEAPP_VNF_DNS_ALLOWED_NETWORKS = env :ONEAPP_VNF_DNS_ALLOWED_NETWORKS, '' + + attr_reader :interfaces, :mgmt + + @interfaces = parse_interfaces ONEAPP_VNF_DNS_INTERFACES + @mgmt = detect_mgmt_interfaces + + def parse_env + interfaces = @interfaces.keys - @mgmt + + n2a = addrs_to_nics(interfaces, family: %w[inet]).to_h do |a, n| + [n.first, a] + end + + { + interfaces: @interfaces.select do |nic, _| + interfaces.include?(nic) + end.to_h do |nic, info| + info[:addr] = n2a[nic] + [nic, info] + end, + + nameservers: ONEAPP_VNF_DNS_NAMESERVERS.split(%r{[ ,;]}) + .map(&:strip) + .reject(&:empty?), + + networks: if ONEAPP_VNF_DNS_ALLOWED_NETWORKS.empty? then + addrs_to_subnets(interfaces, family: %w[inet]).values.join(%[,]) + else + ONEAPP_VNF_DNS_ALLOWED_NETWORKS + end.split(%r{[ ,;]}) + .map(&:strip) + .reject(&:empty?) + } + end + + def install(initdir: '/etc/init.d') + msg :info, 'DNS::install' + + puts bash <<~SCRIPT + apk --no-cache add dns-root-hints ruby unbound + install -o unbound -g unbound -m u=rwx,go=rx -d /var/log/unbound/ + SCRIPT + + file "#{initdir}/one-dns", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__}" + + output_log="/var/log/one-appliance/one-dns.log" + error_log="/var/log/one-appliance/one-dns.log" + + depend() { + after net firewall keepalived + } + + start_pre() { + rc-service unbound start --nodeps + } + + start() { :; } + + stop() { :; } + + stop_post() { + rc-service unbound stop --nodeps + } + SERVICE + + toggle [:update] + end + + def configure(basedir: '/etc/unbound') + msg :info, 'DNS::configure' + + if ONEAPP_VNF_DNS_ENABLED + proto_yesno = ->(proto) { + udp = ONEAPP_VNF_DNS_UDP_DISABLED ? 'no' : 'yes' + tcp = ONEAPP_VNF_DNS_TCP_DISABLED ? 'no' : 'yes' + case proto + when 'udp', 'udp-upstream' then udp + when 'tcp' then tcp + when 'tcp-upstream' then udp == 'yes' ? 'no' : 'yes' + end + } + + dns_vars = parse_env + + file "#{basedir}/unbound.conf", ERB.new(<<~CONFIG, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true + server: + # verbosity number, 0 is least verbose. 1 is default. + verbosity: 1 + + # specify the interfaces to answer queries from by ip-address. + # The default is to listen to localhost (127.0.0.1 and ::1). + # specify 0.0.0.0 and ::0 to bind to all available interfaces. + # specify every interface[@port] on a new 'interface:' labelled line. + # The listen interfaces are not changed on reload, only on restart. + # LOCALHOST: + interface: 127.0.0.1 + interface: ::1 + # ALL: + # interface: 0.0.0.0 + # interface: ::0 + # WHITELIST: + <%- dns_vars[:interfaces].each do |_, info| -%> + interface: <%= render_interface(info, name: false) %> + <%- end -%> + + # port to answer queries from + # port: 53 + + # specify the interfaces to send outgoing queries to authoritative + # server from by ip-address. If none, the default (all) interface + # is used. Specify every interface on a 'outgoing-interface:' line. + # outgoing-interface: 192.0.2.153 + # outgoing-interface: 2001:DB8::5 + # outgoing-interface: 2001:DB8::6 + + # msec for waiting for an unknown server to reply. Increase if you + # are behind a slow satellite link, to eg. 1128. + unknown-server-time-limit: <%= ONEAPP_VNF_DNS_UPSTREAM_TIMEOUT %> + + # Enable IPv4, "yes" or "no". + do-ip4: yes + + # Enable IPv6, "yes" or "no". + do-ip6: yes + + # Enable UDP, "yes" or "no". + do-udp: <%= proto_yesno.('udp') %> + + # Enable TCP, "yes" or "no". + do-tcp: <%= proto_yesno.('tcp') %> + + # upstream connections use TCP only (and no UDP), "yes" or "no" + # useful for tunneling scenarios, default no. + tcp-upstream: <%= proto_yesno.('tcp-upstream') %> + + # upstream connections also use UDP (even if do-udp is no). + # useful if if you want UDP upstream, but don't provide UDP downstream. + udp-upstream-without-downstream: <%= proto_yesno.('udp-upstream') %> + + # control which clients are allowed to make (recursive) queries + # to this server. Specify classless netblocks with /size and action. + # By default everything is refused, except for localhost. + # Choose deny (drop message), refuse (polite error reply), + # allow (recursive ok), allow_setrd (recursive ok, rd bit is forced on), + # allow_snoop (recursive and nonrecursive ok) + # deny_non_local (drop queries unless can be answered from local-data) + # refuse_non_local (like deny_non_local but polite error reply). + # DEFAULT RULES: + access-control: 0.0.0.0/0 refuse + access-control: ::0/0 refuse + access-control: 127.0.0.0/8 allow + access-control: ::1 allow + access-control: ::ffff:127.0.0.1 allow + # WHITELIST: + <%- dns_vars[:networks].each do |subnet| -%> + access-control: <%= subnet %> allow + <%- end -%> + + # the time to live (TTL) value lower bound, in seconds. Default 0. + # If more than an hour could easily give trouble due to stale data. + cache-min-ttl: 0 + + # the time to live (TTL) value cap for RRsets and messages in the + # cache. Items are not cached for longer. In seconds. + cache-max-ttl: <%= ONEAPP_VNF_DNS_MAX_CACHE_TTL %> + + # TODO: chroot + # if given, a chroot(2) is done to the given directory. + # i.e. you can chroot to the working directory, for example, + # for extra security, but make sure all files are in that directory. + # + # If chroot is enabled, you should pass the configfile (from the + # commandline) as a full path from the original root. After the + # chroot has been performed the now defunct portion of the config + # file path is removed to be able to reread the config after a reload. + # + # All other file paths (working dir, logfile, roothints, and + # key files) can be specified in several ways: + # o as an absolute path relative to the new root. + # o as a relative path to the working directory. + # o as an absolute path relative to the original root. + # In the last case the path is adjusted to remove the unused portion. + # + # The pid file can be absolute and outside of the chroot, it is + # written just prior to performing the chroot and dropping permissions. + # + # Additionally, unbound may need to access /dev/urandom (for entropy). + # How to do this is specific to your OS. + # + # If you give "" no chroot is performed. The path must not end in a /. + # chroot: "" + + # if given, user privileges are dropped (after binding port), + # and the given username is assumed. Default is user "unbound". + # If you give "" no privileges are dropped. + # username: "unbound" + + # the working directory. The relative files in this config are + # relative to this directory. If you give "" the working directory + # is not changed. + # If you give a server: directory: dir before include: file statements + # then those includes can be relative to the working directory. + # directory: "" + + # the log file, "" means log to stderr. + # Use of this option sets use-syslog to "no". + logfile: "/var/log/unbound/unbound.log" + + # Log to syslog(3) if yes. The log facility LOG_DAEMON is used to + # log to. If yes, it overrides the logfile. + # use-syslog: yes + + # Log identity to report. if empty, defaults to the name of argv[0] + # (usually "unbound"). + log-identity: "" + + # print UTC timestamp in ascii to logfile, default is epoch in seconds. + log-time-ascii: yes + + # print one line with time, IP, name, type, class for every query. + log-queries: no + + # print one line per reply, with time, IP, name, type, class, rcode, + # timetoresolve, fromcache and responsesize. + log-replies: no + + # print log lines that say why queries return SERVFAIL to clients. + log-servfail: yes + + # file to read root hints from. + # get one from https://www.internic.net/domain/named.cache + <%- if ONEAPP_VNF_DNS_USE_ROOTSERVERS -%> + root-hints: /usr/share/dns-root-hints/named.root + <%- else -%> + # root-hints: /usr/share/dns-root-hints/named.root + <%- end -%> + + # enable to not answer id.server and hostname.bind queries. + hide-identity: yes + + # enable to not answer version.server and version.bind queries. + hide-version: yes + + # Serve expired responses from cache, with TTL 0 in the response, + # and then attempt to fetch the data afresh. + serve-expired: no + + # Use systemd socket activation for UDP, TCP, and control sockets. + use-systemd: no + + # Detach from the terminal, run in background, "yes" or "no". + # Set the value to "no" when unbound runs as systemd service. + do-daemonize: yes + + # Remote control config section. + remote-control: + control-enable: no + + # Forward zones + # Create entries like below, to make all queries for 'example.com' and + # 'example.org' go to the given list of servers. These servers have to handle + # recursion to other nameservers. List zero or more nameservers by hostname + # or by ipaddress. Use an entry with name "." to forward all queries. + # If you enable forward-first, it attempts without the forward if it fails. + # forward-zone: + # name: "example.com" + # forward-addr: 192.0.2.68 + # forward-addr: 192.0.2.73@5355 # forward to port 5355. + # forward-first: no + # forward-tls-upstream: no + # forward-no-cache: no + # forward-zone: + # name: "example.org" + # forward-host: fwd.example.com + <%- unless ONEAPP_VNF_DNS_USE_ROOTSERVERS -%> + forward-zone: + name: "." + <%- dns_vars[:nameservers].each do |nameserver| -%> + forward-addr: <%= nameserver %> + <%- end -%> + <%- end -%> + CONFIG + + toggle [:enable] + else + toggle [:stop, :disable] + end + end + + def toggle(operations) + operations.each do |op| + msg :debug, "DNS::toggle([:#{op}])" + case op + when :reload + puts bash 'rc-service unbound reload' + when :enable + puts bash 'rc-update add unbound default' + puts bash 'rc-update add one-dns default' + when :disable + puts bash 'rc-update del unbound default ||:' + puts bash 'rc-update del one-dns default ||:' + when :update + puts bash 'rc-update -u' + else + puts bash "rc-service one-dns #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'DNS::bootstrap' + end +end +end diff --git a/appliances/VRouter/DNS/tests.rb b/appliances/VRouter/DNS/tests.rb new file mode 100644 index 00000000..785cc815 --- /dev/null +++ b/appliances/VRouter/DNS/tests.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rspec' + +def clear_env + ENV.delete_if { |name| name.include?('VROUTER_') || name.include?('_VNF_') } +end + +RSpec.describe self do + it 'should provide and parse all env vars (default networks)' do + clear_env + + ENV['ONEAPP_VNF_DNS_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_DNS_TCP_DISABLED'] = 'YES' + ENV['ONEAPP_VNF_DNS_UDP_DISABLED'] = 'YES' + + ENV['ONEAPP_VNF_DNS_UPSTREAM_TIMEOUT'] = '123' + ENV['ONEAPP_VNF_DNS_MAX_CACHE_TTL'] = '234' + + ENV['ONEAPP_VNF_DNS_USE_ROOTSERVERS'] = 'YES' + ENV['ONEAPP_VNF_DNS_NAMESERVERS'] = '1.1.1.1 8.8.8.8' + + ENV['ONEAPP_VNF_DNS_INTERFACES'] = 'eth0 eth1 eth2 eth3' + ENV['ETH0_VROUTER_MANAGEMENT'] = 'YES' + + ENV['ONEAPP_VNF_DNS_ALLOWED_NETWORKS'] = '' + + load './main.rb'; include Service::DNS + + allow(Service::DNS).to receive(:ip_addr_list).and_return([ + { 'ifname' => 'eth0', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '10.20.30.40', + 'prefixlen' => 24 } ] }, + + { 'ifname' => 'eth1', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '20.30.40.50', + 'prefixlen' => 16 } ] }, + + { 'ifname' => 'eth2', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '30.40.50.60', + 'prefixlen' => 8 } ] }, + + { 'ifname' => 'eth3', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '40.50.60.70', + 'prefixlen' => 24 } ] }, + ]) + + expect(Service::DNS.parse_env).to eq ({ + interfaces: { 'eth1' => { addr: '20.30.40.50', name: 'eth1', port: nil }, + 'eth2' => { addr: '30.40.50.60', name: 'eth2', port: nil }, + 'eth3' => { addr: '40.50.60.70', name: 'eth3', port: nil } }, + + nameservers: %w[1.1.1.1 8.8.8.8], + + networks: %w[20.30.0.0/16 30.0.0.0/8 40.50.60.0/24], + }) + end + it 'should provide and parse all env vars' do + clear_env + + ENV['ONEAPP_VNF_DNS_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_DNS_TCP_DISABLED'] = 'YES' + ENV['ONEAPP_VNF_DNS_UDP_DISABLED'] = 'YES' + + ENV['ONEAPP_VNF_DNS_UPSTREAM_TIMEOUT'] = '123' + ENV['ONEAPP_VNF_DNS_MAX_CACHE_TTL'] = '234' + + ENV['ONEAPP_VNF_DNS_USE_ROOTSERVERS'] = 'YES' + ENV['ONEAPP_VNF_DNS_NAMESERVERS'] = '1.1.1.1 8.8.8.8' + + ENV['ONEAPP_VNF_DNS_INTERFACES'] = 'eth0 eth1 eth2 eth3' + ENV['ETH0_VROUTER_MANAGEMENT'] = 'YES' + + ENV['ONEAPP_VNF_DNS_ALLOWED_NETWORKS'] = '20.30.0.0/16 30.0.0.0/8' + + load './main.rb'; include Service::DNS + + allow(Service::DNS).to receive(:ip_addr_list).and_return([ + { 'ifname' => 'eth0', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '10.20.30.40', + 'prefixlen' => 24 } ] }, + + { 'ifname' => 'eth1', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '20.30.40.50', + 'prefixlen' => 16 } ] }, + + { 'ifname' => 'eth2', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '30.40.50.60', + 'prefixlen' => 8 } ] }, + + { 'ifname' => 'eth3', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '40.50.60.70', + 'prefixlen' => 24 } ] }, + ]) + + expect(Service::DNS.parse_env).to eq ({ + interfaces: { 'eth1' => { addr: '20.30.40.50', name: 'eth1', port: nil }, + 'eth2' => { addr: '30.40.50.60', name: 'eth2', port: nil }, + 'eth3' => { addr: '40.50.60.70', name: 'eth3', port: nil } }, + + nameservers: %w[1.1.1.1 8.8.8.8], + + networks: %w[20.30.0.0/16 30.0.0.0/8], + }) + end +end diff --git a/appliances/VRouter/Failover/execute.rb b/appliances/VRouter/Failover/execute.rb new file mode 100644 index 00000000..2b049830 --- /dev/null +++ b/appliances/VRouter/Failover/execute.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: false + +require 'json' + +module Service +module Failover + extend self + + VROUTER_ID = env :VROUTER_ID, nil + + SERVICES = { + 'one-router4' => { _ENABLED: 'ONEAPP_VNF_ROUTER4_ENABLED', + fallback: VROUTER_ID.nil? ? 'NO' : 'YES' }, + + 'one-nat4' => { _ENABLED: 'ONEAPP_VNF_NAT4_ENABLED', + fallback: 'NO' }, + + 'one-lvs' => { _ENABLED: 'ONEAPP_VNF_LB_ENABLED', + fallback: 'NO' }, + + 'one-haproxy' => { _ENABLED: 'ONEAPP_VNF_HAPROXY_ENABLED', + fallback: 'NO' }, + + 'one-sdnat4' => { _ENABLED: 'ONEAPP_VNF_SDNAT4_ENABLED', + fallback: 'NO' }, + + 'one-dns' => { _ENABLED: 'ONEAPP_VNF_DNS_ENABLED', + fallback: 'NO' }, + + 'one-dhcp4' => { _ENABLED: 'ONEAPP_VNF_DHCP4_ENABLED', + fallback: 'NO' } + } + + FIFO_PATH = '/run/keepalived/vrrp_notify_fifo.sock' + STATE_PATH = '/run/one-failover.state' + + STATE_TO_DIRECTION = { + 'BACKUP' => :down, + 'DELETED' => :down, + 'FAULT' => :down, + 'MASTER' => :up, + 'STOP' => :down, + nil => :stay + } + def to_event(line) + k = [:type, :name, :state, :priority] + v = line.strip.split.map(&:strip).map{|s| s.delete_prefix('"').delete_suffix('"')} + k.zip(v).to_h + end + + def to_task(event) + event[:state].upcase! + + state = load_state + state[:state].upcase! + + if event[:type] != 'GROUP' + direction = :stay + ignored = true + else + if STATE_TO_DIRECTION[event[:state]] == STATE_TO_DIRECTION[state[:state]] + direction = :stay + ignored = false + else + direction = STATE_TO_DIRECTION[event[:state]] + ignored = false + end + save_state event[:state] + end + + { event: event, from: state[:state], to: event[:state], direction: direction, ignored: ignored } + end + + def save_state(state, state_path = STATE_PATH) + content = JSON.fast_generate({ state: state }) + File.open state_path, File::CREAT | File::TRUNC | File::WRONLY do |f| + f.flock File::LOCK_EX + f.write content + end + end + + def load_state(state_path = STATE_PATH) + content = File.open state_path, File::RDONLY do |f| + f.flock File::LOCK_EX + f.read + end + JSON.parse content, symbolize_names: true + rescue Errno::ENOENT + { state: 'UNKNOWN' } + end + + def process_events(fifo_path = FIFO_PATH) + loop do + begin + File.open fifo_path, File::RDONLY do |f| + f.each do |line| + event = to_event line + task = to_task event + msg :info, task + method(task[:direction]).call + end + end + rescue StandardError => e + msg :error, e.full_message + + # NOTE: We always disable all services on fatal errors + # to avoid any potential conflicts. + down + next + ensure + sleep 1 + end + end + end + + def execute + msg :info, 'Failover::execute' + process_events + end + + def update_conf_d(path, key, value) + File.open path, File::CREAT | File::RDWR, 0644 do |f| + f.flock File::LOCK_EX + content = f.read.lines.map(&:strip) + if line = content.find { |line| line =~ /^[#\s]*#{key}\s*=/ } + if value.nil? + line.replace %[##{line}] unless line.start_with?(%[#]) + else + line.replace %[#{key}="#{value}"] + end + else + content << %[#{key}="#{value}"] + end + f.rewind + f.puts content.join(%[\n]) + f.flush + f.truncate f.pos + end + end + + def stay + msg :debug, :STAY + end + + def up + msg :debug, :UP + + load_env + + SERVICES.each do |service, settings| + enabled = env settings[:_ENABLED], settings[:fallback] + + msg :debug, "#{service}(#{enabled ? ':enabled' : ':disabled'})" + + if enabled + update_conf_d "/etc/conf.d/#{service}", 'rc_need', nil + + bash <<~SCRIPT, terminate: false + rc-update -u ||: + rc-service #{service} restart ||: + SCRIPT + else + update_conf_d "/etc/conf.d/#{service}", 'rc_need', 'THIS-SERVICE-IS-MASKED' + + bash <<~SCRIPT, terminate: false + rc-update -u ||: + rc-service #{service} stop ||: + SCRIPT + end + + sleep 1 + end + end + + def down + msg :debug, :DOWN + + SERVICES.each do |service, _| + msg :debug, "#{service}(:disabled)" + + update_conf_d "/etc/conf.d/#{service}", 'rc_need', 'THIS-SERVICE-IS-MASKED' + + bash <<~SCRIPT, terminate: false + rc-update -u ||: + rc-service #{service} stop ||: + SCRIPT + + sleep 1 + end + end +end +end diff --git a/appliances/VRouter/Failover/main.rb b/appliances/VRouter/Failover/main.rb new file mode 100644 index 00000000..3f8cc9a9 --- /dev/null +++ b/appliances/VRouter/Failover/main.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative 'execute.rb' + +module Service +module Failover + extend self + + DEPENDS_ON = %w[Service::Keepalived] + + def install(initdir: '/etc/init.d') + msg :info, 'Failover::install' + + puts bash 'apk --no-cache add ruby' + + file "#{initdir}/one-failover", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__} -e Service::Failover.execute" + + command_background="yes" + pidfile="/run/$RC_SVCNAME.pid" + + output_log="/var/log/one-appliance/one-failover.log" + error_log="/var/log/one-appliance/one-failover.log" + + depend() { + need keepalived + after net + } + SERVICE + + toggle [:disable, :update, :stop] + end + + def configure + msg :info, 'Failover::configure' + + puts bash <<~SCRIPT + if [[ "$(virt-what)" != vmware ]]; then + rc-update del open-vm-tools default && rc-update -u ||: + fi + SCRIPT + + toggle [:enable, :start] + end + + def toggle(operations) + operations.each do |op| + msg :info, "Failover::toggle([:#{op}])" + case op + when :enable + puts bash 'rc-update add one-failover default' + when :disable + puts bash 'rc-update del one-failover default ||:' + when :update + puts bash 'rc-update -u' + else + puts bash "rc-service one-failover #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'Failover::bootstrap' + end +end +end diff --git a/appliances/VRouter/HAProxy/execute.rb b/appliances/VRouter/HAProxy/execute.rb new file mode 100644 index 00000000..bbb62fe0 --- /dev/null +++ b/appliances/VRouter/HAProxy/execute.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' + +module Service +module HAProxy + extend self + + VROUTER_ID = env :VROUTER_ID, nil + + def extract_backends(objects = {}) + static = backends.from_env(prefix: 'ONEAPP_VNF_HAPROXY_LB') + + dynamic = VROUTER_ID.nil? ? backends.from_vms(objects, prefix: 'ONEGATE_HAPROXY_LB') + : backends.from_vnets(objects, prefix: 'ONEGATE_HAPROXY_LB') + + # NOTE: This ensures that backends can be added dynamically only to statically defined LBs. + merged = hashmap.combine static, backends.intersect(static, dynamic) + + # Replace all "" placeholders where possible. + backends.resolve_vips merged + end + + def render_servers_cfg(haproxy_vars, basedir: '/etc/haproxy') + addrs = addrs_to_nics.keys + + file "#{basedir}/servers.cfg", ERB.new(<<~SERVERS, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true + <%- haproxy_vars[:by_endpoint]&.each do |(lb_idx, ip, port), servers| -%> + <%- if addrs.include?(ip) -%> + frontend lb<%= lb_idx %>_<%= port %> + mode tcp + bind <%= ip %>:<%= port %> + default_backend lb<%= lb_idx %>_<%= port %> + + backend lb<%= lb_idx %>_<%= port %> + mode tcp + balance roundrobin + option tcp-check + <%- servers&.values&.each do |s| -%> + server lb<%= lb_idx %>_<%= s[:host] %>_<%= s[:port] %> <%= s[:host] %>:<%= s[:port] %> check observe layer4 error-limit 50 on-error mark-down + <% end %> + <%- end -%> + <%- end -%> + SERVERS + end + + def execute(basedir: '/etc/haproxy') + msg :info, 'HAProxy::execute' + + # Handle "static" load-balancers. + render_servers_cfg extract_backends, basedir: basedir + toggle [:reload] + + if ONEAPP_VNF_HAPROXY_ONEGATE_ENABLED + prev = [] + + get_objects = VROUTER_ID.nil? ? :get_service_vms : :get_vrouter_vnets + + loop do + unless (objects = method(get_objects).call).empty? + if prev != (this = extract_backends(objects)) + msg :debug, this + + render_servers_cfg this, basedir: basedir + + toggle [:reload] + end + + prev = this + end + + sleep ONEAPP_VNF_HAPROXY_REFRESH_RATE.to_i + end + else + sleep + end + end +end +end diff --git a/appliances/VRouter/HAProxy/main.rb b/appliances/VRouter/HAProxy/main.rb new file mode 100644 index 00000000..940b7c70 --- /dev/null +++ b/appliances/VRouter/HAProxy/main.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' +require_relative 'execute.rb' + +module Service +module HAProxy + extend self + + DEPENDS_ON = %w[Service::Failover] + + ONEAPP_VNF_HAPROXY_ENABLED = env :ONEAPP_VNF_HAPROXY_ENABLED, 'NO' + ONEAPP_VNF_HAPROXY_ONEGATE_ENABLED = env :ONEAPP_VNF_HAPROXY_ONEGATE_ENABLED, 'NO' + + ONEAPP_VNF_HAPROXY_REFRESH_RATE = env :ONEAPP_VNF_HAPROXY_REFRESH_RATE, '30' + + def install(initdir: '/etc/init.d') + msg :info, 'HAProxy::install' + + puts bash 'apk --no-cache add haproxy ruby' + + file "#{initdir}/one-haproxy", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__} -e Service::HAProxy.execute" + + command_background="yes" + pidfile="/run/$RC_SVCNAME.pid" + + output_log="/var/log/one-appliance/one-haproxy.log" + error_log="/var/log/one-appliance/one-haproxy.log" + + depend() { + after net keepalived + } + + start_pre() { + rc-service haproxy start --nodeps + } + + stop_post() { + rc-service haproxy stop --nodeps + } + SERVICE + + toggle [:update] + end + + def configure(basedir: '/etc/haproxy', confdir: '/etc/conf.d') + msg :info, 'HAProxy::configure' + + if ONEAPP_VNF_HAPROXY_ENABLED + file "#{confdir}/haproxy", <<~CONFIG, mode: 'u=rw,g=r,o=', overwrite: true + HAPROXY_CONF="#{basedir}" + CONFIG + + file "#{basedir}/haproxy.cfg", <<~CONFIG, mode: 'u=rw,g=r,o=', overwrite: true + global + log /dev/log local0 + log /dev/log local1 notice + stats socket /var/run/haproxy.sock mode 666 level admin + stats timeout 120s + user haproxy + group haproxy + daemon + + defaults + log global + retries 3 + maxconn 2000 + timeout connect 5s + timeout client 120s + timeout server 120s + CONFIG + + toggle [:enable] + else + toggle [:stop, :disable] + end + end + + def toggle(operations) + operations.each do |op| + msg :debug, "HAProxy::toggle([:#{op}])" + case op + when :reload + puts bash 'rc-service haproxy start' + puts bash 'rc-service haproxy reload' + when :enable + puts bash 'rc-update add haproxy default' + puts bash 'rc-update add one-haproxy default' + when :disable + puts bash 'rc-update del haproxy default ||:' + puts bash 'rc-update del one-haproxy default ||:' + when :update + puts bash 'rc-update -u' + else + puts bash "rc-service one-haproxy #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'HAProxy::bootstrap' + end +end +end diff --git a/appliances/VRouter/HAProxy/tests.rb b/appliances/VRouter/HAProxy/tests.rb new file mode 100644 index 00000000..a875cd14 --- /dev/null +++ b/appliances/VRouter/HAProxy/tests.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require 'json' +require 'rspec' +require 'tmpdir' + +def clear_env + ENV.delete_if { |name| name.include?('VROUTER_') || name.include?('_HAPROXY') } +end + +RSpec.describe self do + it 'should provide and parse all env vars (static)' do + clear_env + + ENV['ONEAPP_VNF_HAPROXY_ENABLED'] = 'YES' + + ENV['ONEAPP_VNF_HAPROXY_REFRESH_RATE'] = '' + + ENV['ONEAPP_VNF_HAPROXY_LB0_IP'] = '10.2.10.69' + ENV['ONEAPP_VNF_HAPROXY_LB0_PORT'] = '1234' + + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER0_HOST'] = '10.2.100.10' + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER0_PORT'] = '12345' + + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER1_HOST'] = '10.2.100.20' + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER1_PORT'] = '12345' + + ENV['ONEAPP_VNF_HAPROXY_LB1_IP'] = '10.2.20.69' + ENV['ONEAPP_VNF_HAPROXY_LB1_PORT'] = '4321' + + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER0_HOST'] = '10.2.200.10' + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER0_PORT'] = '54321' + + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER1_HOST'] = '10.2.200.20' + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER1_PORT'] = '54321' + + load './main.rb'; include Service::HAProxy + + Service::HAProxy.const_set :VROUTER_ID, '86' + + expect(Service::HAProxy::ONEAPP_VNF_HAPROXY_ENABLED).to be true + expect(Service::HAProxy::ONEAPP_VNF_HAPROXY_REFRESH_RATE).to eq '30' + + expect(Service::HAProxy.extract_backends).to eq({ + by_endpoint: { + [ 0, '10.2.10.69', '1234' ] => + { [ '10.2.100.10', '12345' ] => { host: '10.2.100.10', port: '12345' }, + [ '10.2.100.20', '12345' ] => { host: '10.2.100.20', port: '12345' } }, + + [ 1, '10.2.20.69', '4321' ] => + { [ '10.2.200.10', '54321' ] => { host: '10.2.200.10', port: '54321' }, + [ '10.2.200.20', '54321' ] => { host: '10.2.200.20', port: '54321' } } }, + + options: { 0 => { ip: '10.2.10.69', port: '1234' }, + 1 => { ip: '10.2.20.69', port: '4321' } } + }) + end + + it 'should render servers.cfg (static)' do + clear_env + + ENV['ONEAPP_VNF_HAPROXY_ENABLED'] = 'YES' + + ENV['ONEAPP_VNF_HAPROXY_REFRESH_RATE'] = '' + + ENV['ONEAPP_VNF_HAPROXY_LB0_IP'] = '10.2.10.69' + ENV['ONEAPP_VNF_HAPROXY_LB0_PORT'] = '1234' + + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER0_HOST'] = '10.2.100.10' + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER0_PORT'] = '12345' + + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER1_HOST'] = '10.2.100.20' + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER1_PORT'] = '12345' + + ENV['ONEAPP_VNF_HAPROXY_LB1_IP'] = '10.2.10.69' + ENV['ONEAPP_VNF_HAPROXY_LB1_PORT'] = '4321' + + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER0_HOST'] = '10.2.200.10' + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER0_PORT'] = '54321' + + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER1_HOST'] = '10.2.200.20' + ENV['ONEAPP_VNF_HAPROXY_LB1_SERVER1_PORT'] = '54321' + + load './main.rb'; include Service::HAProxy + + Service::HAProxy.const_set :VROUTER_ID, '86' + + allow(Service::HAProxy).to receive(:toggle).and_return(nil) + allow(Service::HAProxy).to receive(:sleep).and_return(nil) + allow(Service::HAProxy).to receive(:addrs_to_nics).and_return({ + '10.2.10.69' => ['eth0'] + }) + output = <<~STATIC + frontend lb0_1234 + mode tcp + bind 10.2.10.69:1234 + default_backend lb0_1234 + + backend lb0_1234 + mode tcp + balance roundrobin + option tcp-check + server lb0_10.2.100.10_12345 10.2.100.10:12345 check observe layer4 error-limit 50 on-error mark-down + server lb0_10.2.100.20_12345 10.2.100.20:12345 check observe layer4 error-limit 50 on-error mark-down + + frontend lb1_4321 + mode tcp + bind 10.2.10.69:4321 + default_backend lb1_4321 + + backend lb1_4321 + mode tcp + balance roundrobin + option tcp-check + server lb1_10.2.200.10_54321 10.2.200.10:54321 check observe layer4 error-limit 50 on-error mark-down + server lb1_10.2.200.20_54321 10.2.200.20:54321 check observe layer4 error-limit 50 on-error mark-down + STATIC + Dir.mktmpdir do |dir| + Service::HAProxy.execute basedir: dir + result = File.read "#{dir}/servers.cfg" + expect(result.strip).to eq output.strip + end + end + + it 'should render servers.cfg (dynamic)' do + clear_env + + ENV['ONEAPP_VNF_HAPROXY_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_HAPROXY_ONEGATE_ENABLED'] = 'YES' + + ENV['ONEAPP_VNF_HAPROXY_REFRESH_RATE'] = '' + + ENV['ONEAPP_VNF_HAPROXY_LB0_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_HAPROXY_LB0_PORT'] = '6969' + + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER0_HOST'] = '10.2.11.200' + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER0_PORT'] = '1234' + + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER1_HOST'] = '10.2.11.201' + ENV['ONEAPP_VNF_HAPROXY_LB0_SERVER1_PORT'] = '1234' + + ENV['ONEAPP_VNF_HAPROXY_LB1_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_HAPROXY_LB1_PORT'] = '8686' + + (vnets ||= []) << JSON.parse(<<~'VNET0') + { + "VNET": { + "ID": "0", + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "LEASES": { + "LEASE": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "6969", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.202", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "1234", + "ONEGATE_HAPROXY_LB0_SERVER_WEIGHT": "1" + }, + { + "IP": "10.2.11.201", + "MAC": "02:00:0a:02:0b:c9", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "6969", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.201", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "1234", + "ONEGATE_HAPROXY_LB0_SERVER_WEIGHT": "1", + + "ONEGATE_HAPROXY_LB1_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB1_PORT": "8686", + "ONEGATE_HAPROXY_LB1_SERVER_HOST": "10.2.11.201", + "ONEGATE_HAPROXY_LB1_SERVER_PORT": "4321", + "ONEGATE_HAPROXY_LB1_SERVER_WEIGHT": "1" + }, + { + "IP": "10.2.11.200", + "MAC": "02:00:0a:02:0b:c8", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "6969", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.200", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "1234", + "ONEGATE_HAPROXY_LB0_SERVER_WEIGHT": "1", + + "ONEGATE_HAPROXY_LB1_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB1_PORT": "8686", + "ONEGATE_HAPROXY_LB1_SERVER_HOST": "10.2.11.200", + "ONEGATE_HAPROXY_LB1_SERVER_PORT": "4321", + "ONEGATE_HAPROXY_LB1_SERVER_WEIGHT": "1" + } + ] + } + } + ] + } + } + } + VNET0 + + load './main.rb'; include Service::HAProxy + + Service::HAProxy.const_set :VROUTER_ID, '86' + + allow(Service::HAProxy).to receive(:addrs_to_nics).and_return({ + '10.2.11.86' => ['eth0'] + }) + output = <<~'DYNAMIC' + frontend lb0_6969 + mode tcp + bind 10.2.11.86:6969 + default_backend lb0_6969 + + backend lb0_6969 + mode tcp + balance roundrobin + option tcp-check + server lb0_10.2.11.200_1234 10.2.11.200:1234 check observe layer4 error-limit 50 on-error mark-down + server lb0_10.2.11.201_1234 10.2.11.201:1234 check observe layer4 error-limit 50 on-error mark-down + server lb0_10.2.11.202_1234 10.2.11.202:1234 check observe layer4 error-limit 50 on-error mark-down + + frontend lb1_8686 + mode tcp + bind 10.2.11.86:8686 + default_backend lb1_8686 + + backend lb1_8686 + mode tcp + balance roundrobin + option tcp-check + server lb1_10.2.11.201_4321 10.2.11.201:4321 check observe layer4 error-limit 50 on-error mark-down + server lb1_10.2.11.200_4321 10.2.11.200:4321 check observe layer4 error-limit 50 on-error mark-down + DYNAMIC + Dir.mktmpdir do |dir| + haproxy_vars = Service::HAProxy.extract_backends vnets + Service::HAProxy.render_servers_cfg haproxy_vars, basedir: dir + result = File.read "#{dir}/servers.cfg" + expect(result.strip).to eq output.strip + end + end + + it 'should render servers.cfg (dynamic/OneFlow)' do + clear_env + + ENV['ONEAPP_VNF_HAPROXY_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_HAPROXY_ONEGATE_ENABLED'] = 'YES' + + ENV['ONEAPP_VNF_HAPROXY_REFRESH_RATE'] = '' + + ENV['ONEAPP_VNF_HAPROXY_LB0_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_HAPROXY_LB0_PORT'] = '5432' + + (vms ||= []) << JSON.parse(<<~'VM0') + { + "VM": { + "NAME": "server_0_(service_23)", + "ID": "435", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "5432", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.202", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "2345", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.122", + "MAC": "02:00:ac:14:00:7a", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM0 + (vms ||= []) << JSON.parse(<<~'VM1') + { + "VM": { + "NAME": "server_1_(service_23)", + "ID": "436", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "5432", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.203", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "2345", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.203", + "MAC": "02:00:0a:02:0b:cb", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.123", + "MAC": "02:00:ac:14:00:7b", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM1 + + load './main.rb'; include Service::HAProxy + + Service::HAProxy.const_set :VROUTER_ID, nil + + allow(Service::HAProxy).to receive(:addrs_to_nics).and_return({ + '10.2.11.86' => ['eth0'] + }) + output = <<~'DYNAMIC' + frontend lb0_5432 + mode tcp + bind 10.2.11.86:5432 + default_backend lb0_5432 + + backend lb0_5432 + mode tcp + balance roundrobin + option tcp-check + server lb0_10.2.11.202_2345 10.2.11.202:2345 check observe layer4 error-limit 50 on-error mark-down + server lb0_10.2.11.203_2345 10.2.11.203:2345 check observe layer4 error-limit 50 on-error mark-down + DYNAMIC + Dir.mktmpdir do |dir| + haproxy_vars = Service::HAProxy.extract_backends vms + Service::HAProxy.render_servers_cfg haproxy_vars, basedir: dir + result = File.read "#{dir}/servers.cfg" + expect(result.strip).to eq output.strip + end + end +end diff --git a/appliances/VRouter/Keepalived/main.rb b/appliances/VRouter/Keepalived/main.rb new file mode 100644 index 00000000..3112cf8f --- /dev/null +++ b/appliances/VRouter/Keepalived/main.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' + +module Service +module Keepalived + extend self + + DEPENDS_ON = %w[] + + VROUTER_KEEPALIVED_PASSWORD = env :VROUTER_KEEPALIVED_PASSWORD, nil # legacy + ONEAPP_VNF_KEEPALIVED_PASSWORD = env :ONEAPP_VNF_KEEPALIVED_PASSWORD, VROUTER_KEEPALIVED_PASSWORD # must be under 8 characters + + ONEAPP_VNF_KEEPALIVED_INTERVAL = env :ONEAPP_VNF_KEEPALIVED_INTERVAL, '1' + ONEAPP_VNF_KEEPALIVED_PRIORITY = env :ONEAPP_VNF_KEEPALIVED_PRIORITY, '100' + + VROUTER_KEEPALIVED_ID = env :VROUTER_KEEPALIVED_ID, nil + ONEAPP_VNF_KEEPALIVED_VRID = env :ONEAPP_VNF_KEEPALIVED_VRID, VROUTER_KEEPALIVED_ID + + ONEAPP_VNF_KEEPALIVED_INTERFACES = env :ONEAPP_VNF_KEEPALIVED_INTERFACES, '' # nil -> none, empty -> all + + attr_reader :interfaces, :mgmt + + @interfaces = parse_interfaces ONEAPP_VNF_KEEPALIVED_INTERFACES + @mgmt = detect_mgmt_interfaces + + def parse_env + vips = detect_vips + + (@interfaces.keys - @mgmt).each_with_object({}) do |nic, vars| + vars[:by_nic] ||= {} + vars[:by_nic][nic] = { + password: env("ONEAPP_VNF_KEEPALIVED_#{nic.upcase}_PASSWORD", ONEAPP_VNF_KEEPALIVED_PASSWORD), + interval: env("ONEAPP_VNF_KEEPALIVED_#{nic.upcase}_INTERVAL", ONEAPP_VNF_KEEPALIVED_INTERVAL), + priority: env("ONEAPP_VNF_KEEPALIVED_#{nic.upcase}_PRIORITY", ONEAPP_VNF_KEEPALIVED_PRIORITY), + vrid: env("ONEAPP_VNF_KEEPALIVED_#{nic.upcase}_VRID", ONEAPP_VNF_KEEPALIVED_VRID), + vips: vips[nic]&.values || [] + } + vars[:by_vrid] ||= {} + vars[:by_vrid][vars[:by_nic][nic][:vrid]] ||= {} + vars[:by_vrid][vars[:by_nic][nic][:vrid]][nic] = vars[:by_nic][nic] + end + end + + def install + msg :info, 'Keepalived::install' + + puts bash 'apk --no-cache add keepalived' + end + + def configure(basedir: '/etc/keepalived') + msg :info, 'Keepalived::configure' + + file "#{basedir}/keepalived.conf", <<~MAIN, mode: 'u=rw,g=r,o=', overwrite: true + include #{basedir}/conf.d/*.conf + MAIN + + file "#{basedir}/conf.d/global.conf", <<~GLOBAL, mode: 'u=rw,g=r,o=', overwrite: true + global_defs { + vrrp_notify_fifo /run/keepalived/vrrp_notify_fifo.sock + fifo_write_vrrp_states_on_reload + } + GLOBAL + + keepalived_vars = parse_env + + file "#{basedir}/conf.d/vrrp.conf", ERB.new(<<~VRRP, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true + <%- unless keepalived_vars[:by_vrid].nil? || keepalived_vars[:by_vrid].empty? -%> + vrrp_sync_group VRouter { + group { + <%- keepalived_vars[:by_vrid].each do |_, nics| -%> + <%= nics.keys.first.upcase %> + <%- end -%> + } + } + <%- keepalived_vars[:by_vrid].each do |vrid, nics| -%> + vrrp_instance <%= nics.keys.first.upcase %> { + state BACKUP + interface <%= nics.keys.first.downcase %> + virtual_router_id <%= vrid %> + priority <%= nics.values.first[:priority] %> + advert_int <%= nics.values.first[:interval] %> + virtual_ipaddress { + <%- nics.each do |nic, opt| -%> + <%- opt[:vips].compact.reject(&:empty?).each do |vip| -%> + <%= vip %> dev <%= nic.downcase %> + <%- end -%> + <%- end -%> + } + <%- unless nics.values.first[:password].nil? -%> + authentication { + auth_type PASS + auth_pass <%= nics.values.first[:password] %> + } + <%- end -%> + } + <%- end -%> + <%- end -%> + VRRP + + # NOTE: It is important to restart keepalived at this point + # to properly re-send vrrp fifo updates to one-failover. + # Re-configure can be triggered by direct context changes + # or for example a NIC hotplug. + toggle [:enable, :restart] + end + + def toggle(operations) + operations.each do |op| + msg :info, "Keepalived::toggle([:#{op}])" + case op + when :enable + puts bash 'rc-update add keepalived default' + when :disable + puts bash 'rc-update del keepalived default ||:' + when :update + puts bash 'rc-update -u' + else + puts bash "rc-service keepalived #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'Keepalived::bootstrap' + end +end +end diff --git a/appliances/VRouter/Keepalived/tests.rb b/appliances/VRouter/Keepalived/tests.rb new file mode 100644 index 00000000..dc483142 --- /dev/null +++ b/appliances/VRouter/Keepalived/tests.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'rspec' +require 'tmpdir' + +def clear_env + ENV.delete_if { |name| name.include?('VROUTER_') || name.include?('_VNF_') } +end + +RSpec.describe self do + it 'should provide and parse all env vars' do + clear_env + + ENV['ONEAPP_VNF_KEEPALIVED_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_PRIORITY'] = '100' + + ENV['VROUTER_KEEPALIVED_ID'] = '11' + ENV['ONEAPP_VNF_KEEPALIVED_VRID'] = '11' + + ENV['ONEAPP_VNF_KEEPALIVED_INTERFACES'] = 'eth0 eth1' + ENV['ETH8_VROUTER_MANAGEMENT'] = 'YES' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_PRIORITY'] = '100' + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_VRID'] = '11' + + ENV['ETH0_VROUTER_IP'] = '10.2.11.69' + ENV['ONEAPP_VROUTER_ETH0_VIP0'] = '10.2.11.69' + ENV['ONEAPP_VROUTER_ETH0_VIP1'] = '10.2.11.86' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_PRIORITY'] = '100' + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_VRID'] = '11' + + ENV['ETH1_VROUTER_IP'] = '10.2.12.69' + ENV['ONEAPP_VROUTER_ETH1_VIP0'] = '10.2.12.69' + ENV['ONEAPP_VROUTER_ETH1_VIP1'] = '10.2.12.86' + + load './main.rb'; include Service::Keepalived + + expect(Service::Keepalived::ONEAPP_VNF_KEEPALIVED_INTERVAL).to eq '1' + expect(Service::Keepalived::ONEAPP_VNF_KEEPALIVED_PRIORITY).to eq '100' + + expect(Service::Keepalived::VROUTER_KEEPALIVED_ID).to eq '11' + expect(Service::Keepalived::ONEAPP_VNF_KEEPALIVED_VRID).to eq '11' + + expect(Service::Keepalived.interfaces.keys).to eq %w[eth0 eth1] + expect(Service::Keepalived.mgmt).to eq %w[eth8] + + vars = Service::Keepalived.parse_env + + expect(vars[:by_nic]['eth0'][:interval]).to eq '1' + expect(vars[:by_nic]['eth0'][:priority]).to eq '100' + expect(vars[:by_nic]['eth0'][:vrid]).to eq '11' + expect(vars[:by_nic]['eth0'][:vips][0]).to eq '10.2.11.69' + expect(vars[:by_nic]['eth0'][:vips][1]).to eq '10.2.11.86' + + expect(vars[:by_nic]['eth1'][:interval]).to eq '1' + expect(vars[:by_nic]['eth1'][:priority]).to eq '100' + expect(vars[:by_nic]['eth1'][:vrid]).to eq '11' + expect(vars[:by_nic]['eth1'][:vips][0]).to eq '10.2.12.69' + expect(vars[:by_nic]['eth1'][:vips][1]).to eq '10.2.12.86' + + expect(vars[:by_vrid]['11'].keys).to eq %w[eth0 eth1] + end + + it 'should get default values from legacy env vars' do + clear_env + + ENV['ONEAPP_VNF_KEEPALIVED_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_PRIORITY'] = '100' + + ENV['VROUTER_KEEPALIVED_ID'] = '21' + ENV['ONEAPP_VNF_KEEPALIVED_VRID'] = '' + + ENV['ONEAPP_VNF_KEEPALIVED_INTERFACES'] = 'eth0 eth1' + + ENV['ETH0_VROUTER_IP'] = '10.2.21.69' + ENV['ONEAPP_VROUTER_ETH0_VIP0'] = '' + + ENV['ETH1_VROUTER_IP'] = '10.2.22.69' + ENV['ONEAPP_VROUTER_ETH1_VIP0'] = '' + + load './main.rb'; include Service::Keepalived + + expect(Service::Keepalived::VROUTER_KEEPALIVED_ID).to eq '21' + expect(Service::Keepalived::ONEAPP_VNF_KEEPALIVED_VRID).to eq '21' + + expect(Service::Keepalived.interfaces.keys).to eq %w[eth0 eth1] + + vars = Service::Keepalived.parse_env + + expect(vars[:by_nic]['eth0'][:vrid]).to eq '21' + expect(vars[:by_nic]['eth0'][:vips][0]).to eq '10.2.21.69' + + expect(vars[:by_nic]['eth1'][:vrid]).to eq '21' + expect(vars[:by_nic]['eth1'][:vips][0]).to eq '10.2.22.69' + + expect(vars[:by_vrid]['21'].keys).to eq %w[eth0 eth1] + end + + it 'should render vrrp.conf' do + clear_env + + ENV['ONEAPP_VNF_KEEPALIVED_INTERFACES'] = 'eth0 eth1 eth2 eth3' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_PRIORITY'] = '100' + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_VRID'] = '30' + + ENV['ONEAPP_VROUTER_ETH0_VIP0'] = '10.2.30.69' + ENV['ONEAPP_VROUTER_ETH0_VIP1'] = '10.2.30.86' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_PRIORITY'] = '100' + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_VRID'] = '30' + + ENV['ONEAPP_VROUTER_ETH1_VIP0'] = '10.2.31.69' + ENV['ONEAPP_VROUTER_ETH1_VIP1'] = '10.2.31.86' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH2_INTERVAL'] = '1' + ENV['ONEAPP_VNF_KEEPALIVED_ETH2_PRIORITY'] = '100' + ENV['ONEAPP_VNF_KEEPALIVED_ETH2_VRID'] = '31' + + ENV['ONEAPP_VROUTER_ETH2_VIP0'] = '10.2.32.69/24' + ENV['ONEAPP_VROUTER_ETH2_VIP1'] = '10.2.32.86/24' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH3_VRID'] = '32' + + load './main.rb'; include Service::Keepalived + + allow(Service::Keepalived).to receive(:toggle).and_return(nil) + output = <<~'VRRP' + vrrp_sync_group VRouter { + group { + ETH0 + ETH2 + ETH3 + } + } + vrrp_instance ETH0 { + state BACKUP + interface eth0 + virtual_router_id 30 + priority 100 + advert_int 1 + virtual_ipaddress { + 10.2.30.69 dev eth0 + 10.2.30.86 dev eth0 + 10.2.31.69 dev eth1 + 10.2.31.86 dev eth1 + } + } + vrrp_instance ETH2 { + state BACKUP + interface eth2 + virtual_router_id 31 + priority 100 + advert_int 1 + virtual_ipaddress { + 10.2.32.69/24 dev eth2 + 10.2.32.86/24 dev eth2 + } + } + vrrp_instance ETH3 { + state BACKUP + interface eth3 + virtual_router_id 32 + priority 100 + advert_int 1 + virtual_ipaddress { + } + } + VRRP + Dir.mktmpdir do |dir| + Service::Keepalived.configure basedir: dir + result = File.read "#{dir}/conf.d/vrrp.conf" + expect(result.strip).to eq output.strip + end + end + + it 'should render vrrp.conf (passwords)' do + clear_env + + ENV['VROUTER_KEEPALIVED_PASSWORD'] = 'asd123' + ENV['ONEAPP_VNF_KEEPALIVED_ETH3_PASSWORD'] = 'asd456' + + ENV['ONEAPP_VNF_KEEPALIVED_INTERFACES'] = 'eth0 eth1 eth2 eth3' + + ENV['ONEAPP_VNF_KEEPALIVED_ETH0_VRID'] = '30' + ENV['ONEAPP_VNF_KEEPALIVED_ETH1_VRID'] = '30' + ENV['ONEAPP_VNF_KEEPALIVED_ETH2_VRID'] = '31' + ENV['ONEAPP_VNF_KEEPALIVED_ETH3_VRID'] = '32' + + load './main.rb'; include Service::Keepalived + + allow(Service::Keepalived).to receive(:toggle).and_return(nil) + output = <<~'VRRP' + vrrp_sync_group VRouter { + group { + ETH0 + ETH2 + ETH3 + } + } + vrrp_instance ETH0 { + state BACKUP + interface eth0 + virtual_router_id 30 + priority 100 + advert_int 1 + virtual_ipaddress { + } + authentication { + auth_type PASS + auth_pass asd123 + } + } + vrrp_instance ETH2 { + state BACKUP + interface eth2 + virtual_router_id 31 + priority 100 + advert_int 1 + virtual_ipaddress { + } + authentication { + auth_type PASS + auth_pass asd123 + } + } + vrrp_instance ETH3 { + state BACKUP + interface eth3 + virtual_router_id 32 + priority 100 + advert_int 1 + virtual_ipaddress { + } + authentication { + auth_type PASS + auth_pass asd456 + } + } + VRRP + Dir.mktmpdir do |dir| + Service::Keepalived.configure basedir: dir + result = File.read "#{dir}/conf.d/vrrp.conf" + expect(result.strip).to eq output.strip + end + end +end diff --git a/appliances/VRouter/LVS/execute.rb b/appliances/VRouter/LVS/execute.rb new file mode 100644 index 00000000..b17fbb01 --- /dev/null +++ b/appliances/VRouter/LVS/execute.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' + +module Service +module LVS + extend self + + VROUTER_ID = env :VROUTER_ID, nil + + def extract_backends(objects = {}) + static = backends.from_env(prefix: 'ONEAPP_VNF_LB') + + dynamic = VROUTER_ID.nil? ? backends.from_vms(objects, prefix: 'ONEGATE_LB') + : backends.from_vnets(objects, prefix: 'ONEGATE_LB') + + # NOTE: This ensures that backends can be added dynamically only to statically defined LBs. + merged = hashmap.combine static, backends.intersect(static, dynamic) + + # Replace all "" placeholders where possible. + backends.resolve_vips merged + end + + def render_lvs_conf(lvs_vars, basedir: '/etc/keepalived') + addrs = addrs_to_nics.keys + + file "#{basedir}/conf.d/lvs.conf", ERB.new(<<~LVS, trim_mode: '-').result(binding), mode: 'u=rw,g=r,o=', overwrite: true + <%- lvs_vars[:by_endpoint]&.each do |(lb_idx, ip, port), servers| -%> + <%- if addrs.include?(ip) -%> + virtual_server <%= ip %> <%= port %> { + <%- unless lvs_vars[:options][lb_idx][:scheduler].nil? -%> + lb_algo <%= lvs_vars[:options][lb_idx][:scheduler] %> + <%- end -%> + <%- unless lvs_vars[:options][lb_idx][:method].nil? -%> + lb_kind <%= lvs_vars[:options][lb_idx][:method] %> + <%- end -%> + <%- unless lvs_vars[:options][lb_idx][:protocol].nil? -%> + protocol <%= lvs_vars[:options][lb_idx][:protocol] %> + <%- end -%> + + <%- servers&.values&.each do |s| -%> + real_server <%= s[:host] %> <%= s[:port] %> { + <%- unless s[:weight].nil? -%> + weight <%= s[:weight] %> + <%- end -%> + <%- unless s[:ulimit].nil? -%> + uthreshold <%= s[:ulimit] %> + <%- end -%> + <%- unless s[:llimit].nil? -%> + lthreshold <%= s[:llimit] %> + <%- end -%> + PING_CHECK { + retry 4 + } + } + <%- end -%> + } + <%- end -%> + <%- end -%> + LVS + end + + def execute(basedir: '/etc/keepalived') + msg :info, 'LVS::execute' + + # Handle "static" load-balancers. + render_lvs_conf extract_backends, basedir: basedir + toggle [:reload] + + if ONEAPP_VNF_LB_ONEGATE_ENABLED + prev = [] + + get_objects = VROUTER_ID.nil? ? :get_service_vms : :get_vrouter_vnets + + loop do + unless (objects = method(get_objects).call).empty? + if prev != (this = extract_backends(objects)) + msg :debug, this + + render_lvs_conf this, basedir: basedir + + toggle [:reload] + end + + prev = this + end + + sleep ONEAPP_VNF_LB_REFRESH_RATE.to_i + end + else + sleep + end + end + + def cleanup(basedir: '/etc/keepalived') + msg :info, 'LVS::cleanup' + + file "#{basedir}/conf.d/lvs.conf", '', mode: 'u=rw,g=r,o=', overwrite: true + + toggle [:reload] + end +end +end diff --git a/appliances/VRouter/LVS/main.rb b/appliances/VRouter/LVS/main.rb new file mode 100644 index 00000000..03d1030e --- /dev/null +++ b/appliances/VRouter/LVS/main.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' +require_relative 'execute.rb' + +module Service +module LVS + extend self + + DEPENDS_ON = %w[Service::Failover] + + ONEAPP_VNF_LB_ENABLED = env :ONEAPP_VNF_LB_ENABLED, 'NO' + ONEAPP_VNF_LB_ONEGATE_ENABLED = env :ONEAPP_VNF_LB_ONEGATE_ENABLED, 'NO' + + ONEAPP_VNF_LB_REFRESH_RATE = env :ONEAPP_VNF_LB_REFRESH_RATE, '30' + ONEAPP_VNF_LB_FWMARK_OFFSET = env :ONEAPP_VNF_LB_FWMARK_OFFSET, '10000' + + def install(initdir: '/etc/init.d') + msg :info, 'LVS::install' + + puts bash 'apk --no-cache add ipvsadm ruby' + + file "#{initdir}/one-lvs", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__} -e Service::LVS.execute" + + command_background="yes" + pidfile="/run/$RC_SVCNAME.pid" + + output_log="/var/log/one-appliance/one-lvs.log" + error_log="/var/log/one-appliance/one-lvs.log" + + depend() { + after net keepalived + } + + stop_post() { + $command -r /etc/one-appliance/lib/helpers.rb -r #{__FILE__} -e Service::LVS.cleanup 1>>$output_log 2>>$error_log + } + SERVICE + + toggle [:update] + end + + def configure(basedir: '/etc/keepalived') + msg :info, 'LVS::configure' + + if ONEAPP_VNF_LB_ENABLED + toggle [:enable] + else + toggle [:disable, :reload] + end + end + + def toggle(operations) + operations.each do |op| + msg :debug, "LVS::toggle([:#{op}])" + case op + when :reload + puts bash 'rc-service keepalived reload' + when :enable + puts bash 'rc-update add one-lvs default' + when :disable + puts bash 'rc-update del one-lvs default ||:' + when :update + puts bash 'rc-update -u' + else + puts bash "rc-service one-lvs #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'LVS::bootstrap' + end +end +end diff --git a/appliances/VRouter/LVS/tests.rb b/appliances/VRouter/LVS/tests.rb new file mode 100644 index 00000000..ddfdbaa9 --- /dev/null +++ b/appliances/VRouter/LVS/tests.rb @@ -0,0 +1,514 @@ +# frozen_string_literal: true + +require 'rspec' +require 'tmpdir' + +def clear_env + ENV.delete_if { |name| name.include?('VROUTER_') || name.include?('_LB') } +end + +RSpec.describe self do + it 'should provide defaults (static)' do + clear_env + + ENV['ONEAPP_VNF_LB_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_LB_REFRESH_RATE'] = '' + ENV['ONEAPP_VNF_LB_FWMARK_OFFSET'] = '' + + ENV['ONEAPP_VNF_LB0_IP'] = '10.2.10.69' + ENV['ONEAPP_VNF_LB0_PORT'] = '1234' + ENV['ONEAPP_VNF_LB0_PROTOCOL'] = 'TCP' + + ENV['ONEAPP_VNF_LB0_SERVER0_HOST'] = '10.2.100.10' + ENV['ONEAPP_VNF_LB0_SERVER0_PORT'] = '12345' + + ENV['ONEAPP_VNF_LB0_SERVER1_HOST'] = '10.2.100.20' + ENV['ONEAPP_VNF_LB0_SERVER1_PORT'] = '12345' + + ENV['ONEAPP_VNF_LB1_IP'] = '10.2.20.69' + ENV['ONEAPP_VNF_LB1_PORT'] = '4321' + ENV['ONEAPP_VNF_LB1_PROTOCOL'] = 'TCP' + + ENV['ONEAPP_VNF_LB1_SERVER0_HOST'] = '10.2.200.10' + ENV['ONEAPP_VNF_LB1_SERVER0_PORT'] = '54321' + + ENV['ONEAPP_VNF_LB1_SERVER1_HOST'] = '10.2.200.20' + ENV['ONEAPP_VNF_LB1_SERVER1_PORT'] = '54321' + + load './main.rb'; include Service::LVS + + Service::LVS.const_set :VROUTER_ID, '86' + + expect(Service::LVS::ONEAPP_VNF_LB_ENABLED).to be true + expect(Service::LVS::ONEAPP_VNF_LB_REFRESH_RATE).to eq '30' + expect(Service::LVS::ONEAPP_VNF_LB_FWMARK_OFFSET).to eq '10000' + + expect(Service::LVS.extract_backends).to eq({ + by_endpoint: { + [ 0, '10.2.10.69', '1234' ] => + { [ '10.2.100.10', '12345' ] => { host: '10.2.100.10', port: '12345' }, + [ '10.2.100.20', '12345' ] => { host: '10.2.100.20', port: '12345' } }, + + [ 1, '10.2.20.69', '4321' ] => + { [ '10.2.200.10', '54321' ] => { host: '10.2.200.10', port: '54321' }, + [ '10.2.200.20', '54321' ] => { host: '10.2.200.20', port: '54321' } } }, + + options: { 0 => { ip: '10.2.10.69', port: '1234', protocol: 'TCP' }, + 1 => { ip: '10.2.20.69', port: '4321', protocol: 'TCP' } } + }) + end + + it 'should provide and parse all env vars (static)' do + clear_env + + ENV['ONEAPP_VNF_LB_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_LB_REFRESH_RATE'] = '45' + ENV['ONEAPP_VNF_LB_FWMARK_OFFSET'] = '12345' + + ENV['ONEAPP_VNF_LB0_IP'] = '10.2.10.69' + ENV['ONEAPP_VNF_LB0_PORT'] = '1234' + ENV['ONEAPP_VNF_LB0_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB0_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB0_TIMEOUT'] = '10' + ENV['ONEAPP_VNF_LB0_SCHEDULER'] = 'rr' + + ENV['ONEAPP_VNF_LB0_SERVER0_HOST'] = '10.2.100.10' + ENV['ONEAPP_VNF_LB0_SERVER0_PORT'] = '12345' + ENV['ONEAPP_VNF_LB0_SERVER0_WEIGHT'] = '1' + ENV['ONEAPP_VNF_LB0_SERVER0_ULIMIT'] = '100' + ENV['ONEAPP_VNF_LB0_SERVER0_LLIMIT'] = '0' + + ENV['ONEAPP_VNF_LB0_SERVER1_HOST'] = '10.2.100.20' + ENV['ONEAPP_VNF_LB0_SERVER1_PORT'] = '12345' + ENV['ONEAPP_VNF_LB0_SERVER1_WEIGHT'] = '1' + ENV['ONEAPP_VNF_LB0_SERVER1_ULIMIT'] = '100' + ENV['ONEAPP_VNF_LB0_SERVER1_LLIMIT'] = '0' + + ENV['ONEAPP_VNF_LB1_IP'] = '10.2.20.69' + ENV['ONEAPP_VNF_LB1_PORT'] = '4321' + ENV['ONEAPP_VNF_LB1_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB1_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB1_TIMEOUT'] = '10' + ENV['ONEAPP_VNF_LB1_SCHEDULER'] = 'rr' + + ENV['ONEAPP_VNF_LB1_SERVER0_HOST'] = '10.2.200.10' + ENV['ONEAPP_VNF_LB1_SERVER0_PORT'] = '54321' + ENV['ONEAPP_VNF_LB1_SERVER0_WEIGHT'] = '1' + ENV['ONEAPP_VNF_LB1_SERVER0_ULIMIT'] = '100' + ENV['ONEAPP_VNF_LB1_SERVER0_LLIMIT'] = '0' + + ENV['ONEAPP_VNF_LB1_SERVER1_HOST'] = '10.2.200.20' + ENV['ONEAPP_VNF_LB1_SERVER1_PORT'] = '54321' + ENV['ONEAPP_VNF_LB1_SERVER1_WEIGHT'] = '1' + ENV['ONEAPP_VNF_LB1_SERVER1_ULIMIT'] = '100' + ENV['ONEAPP_VNF_LB1_SERVER1_LLIMIT'] = '0' + + load './main.rb'; include Service::LVS + + Service::LVS.const_set :VROUTER_ID, '86' + + expect(Service::LVS::ONEAPP_VNF_LB_ENABLED).to be true + expect(Service::LVS::ONEAPP_VNF_LB_REFRESH_RATE).to eq '45' + expect(Service::LVS::ONEAPP_VNF_LB_FWMARK_OFFSET).to eq '12345' + + expect(Service::LVS.extract_backends).to eq({ + by_endpoint: { + [ 0, '10.2.10.69', '1234' ] => + { [ '10.2.100.10', '12345' ] => { host: '10.2.100.10', port: '12345', llimit: '0', ulimit: '100', weight: '1' }, + [ '10.2.100.20', '12345' ] => { host: '10.2.100.20', port: '12345', llimit: '0', ulimit: '100', weight: '1' } }, + + [ 1, '10.2.20.69', '4321' ] => + { [ '10.2.200.10', '54321' ] => { host: '10.2.200.10', port: '54321', llimit: '0', ulimit: '100', weight: '1' }, + [ '10.2.200.20', '54321' ] => { host: '10.2.200.20', port: '54321', llimit: '0', ulimit: '100', weight: '1' } } }, + + options: { 0 => { ip: '10.2.10.69', port: '1234', method: 'DR', protocol: 'TCP', scheduler: 'rr', timeout: '10' }, + 1 => { ip: '10.2.20.69', port: '4321', method: 'DR', protocol: 'TCP', scheduler: 'rr', timeout: '10' } } + + }) + end + + it 'should render lvs.cfg (static)' do + clear_env + + ENV['ONEAPP_VNF_LB_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_LB_REFRESH_RATE'] = '' + ENV['ONEAPP_VNF_LB_FWMARK_OFFSET'] = '' + + ENV['ONEAPP_VNF_LB0_IP'] = '10.2.10.69' + ENV['ONEAPP_VNF_LB0_PORT'] = '1234' + ENV['ONEAPP_VNF_LB0_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB0_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB0_TIMEOUT'] = '10' + ENV['ONEAPP_VNF_LB0_SCHEDULER'] = 'rr' + + ENV['ONEAPP_VNF_LB0_SERVER0_HOST'] = '10.2.100.10' + ENV['ONEAPP_VNF_LB0_SERVER0_PORT'] = '12345' + + ENV['ONEAPP_VNF_LB0_SERVER1_HOST'] = '10.2.100.20' + ENV['ONEAPP_VNF_LB0_SERVER1_PORT'] = '12345' + + ENV['ONEAPP_VNF_LB1_IP'] = '10.2.20.69' + ENV['ONEAPP_VNF_LB1_PORT'] = '4321' + ENV['ONEAPP_VNF_LB1_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB1_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB1_TIMEOUT'] = '10' + ENV['ONEAPP_VNF_LB1_SCHEDULER'] = 'rr' + + ENV['ONEAPP_VNF_LB1_SERVER0_HOST'] = '10.2.200.10' + ENV['ONEAPP_VNF_LB1_SERVER0_PORT'] = '54321' + + ENV['ONEAPP_VNF_LB1_SERVER1_HOST'] = '10.2.200.20' + ENV['ONEAPP_VNF_LB1_SERVER1_PORT'] = '54321' + + load './main.rb'; include Service::LVS + + Service::LVS.const_set :VROUTER_ID, '86' + + allow(Service::LVS).to receive(:toggle).and_return(nil) + allow(Service::LVS).to receive(:sleep).and_return(nil) + allow(Service::LVS).to receive(:addrs_to_nics).and_return({ + '10.2.10.69' => ['eth0'], + '10.2.20.69' => ['eth0'] + }) + output = <<~STATIC + virtual_server 10.2.10.69 1234 { + lb_algo rr + lb_kind DR + protocol TCP + + real_server 10.2.100.10 12345 { + PING_CHECK { + retry 4 + } + } + real_server 10.2.100.20 12345 { + PING_CHECK { + retry 4 + } + } + } + virtual_server 10.2.20.69 4321 { + lb_algo rr + lb_kind DR + protocol TCP + + real_server 10.2.200.10 54321 { + PING_CHECK { + retry 4 + } + } + real_server 10.2.200.20 54321 { + PING_CHECK { + retry 4 + } + } + } + STATIC + Dir.mktmpdir do |dir| + Service::LVS.execute basedir: dir + result = File.read "#{dir}/conf.d/lvs.conf" + expect(result.strip).to eq output.strip + end + end + + it 'should render lvs.cfg (dynamic)' do + clear_env + + ENV['ONEAPP_VNF_LB_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_LB_ONEGATE_ENABLED'] = 'YES' + + ENV['ONEAPP_VNF_LB_FWMARK_OFFSET'] = '' + + ENV['ONEAPP_VNF_LB0_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_LB0_PORT'] = '6969' + ENV['ONEAPP_VNF_LB0_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB0_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB0_TIMEOUT'] = '5' + ENV['ONEAPP_VNF_LB0_SCHEDULER'] = 'rr' + + ENV['ONEAPP_VNF_LB0_SERVER0_HOST'] = '10.2.11.200' + ENV['ONEAPP_VNF_LB0_SERVER0_PORT'] = '6969' + + ENV['ONEAPP_VNF_LB0_SERVER1_HOST'] = '10.2.11.201' + ENV['ONEAPP_VNF_LB0_SERVER1_PORT'] = '6969' + + ENV['ONEAPP_VNF_LB1_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_LB1_PORT'] = '8686' + ENV['ONEAPP_VNF_LB1_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB1_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB1_TIMEOUT'] = '5' + ENV['ONEAPP_VNF_LB1_SCHEDULER'] = 'rr' + + (vnets ||= []) << JSON.parse(<<~'VNET0') + { + "VNET": { + "ID": "0", + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "LEASES": { + "LEASE": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "6969", + "ONEGATE_LB0_PROTOCOL": "TCP", + + "ONEGATE_LB0_SERVER_HOST": "10.2.11.202", + "ONEGATE_LB0_SERVER_PORT": "6969", + "ONEGATE_LB0_SERVER_WEIGHT": "3" + }, + { + "IP": "10.2.11.201", + "MAC": "02:00:0a:02:0b:c9", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "6969", + "ONEGATE_LB0_PROTOCOL": "TCP", + + "ONEGATE_LB0_SERVER_HOST": "10.2.11.201", + "ONEGATE_LB0_SERVER_PORT": "6969", + "ONEGATE_LB0_SERVER_WEIGHT": "2", + + "ONEGATE_LB1_IP": "10.2.11.86", + "ONEGATE_LB1_PORT": "8686", + + "ONEGATE_LB1_SERVER_HOST": "10.2.11.201", + "ONEGATE_LB1_SERVER_PORT": "8686", + "ONEGATE_LB1_SERVER_WEIGHT": "2" + }, + { + "IP": "10.2.11.200", + "MAC": "02:00:0a:02:0b:c8", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "6969", + + "ONEGATE_LB0_SERVER_HOST": "10.2.11.200", + "ONEGATE_LB0_SERVER_PORT": "6969", + "ONEGATE_LB0_SERVER_WEIGHT": "1", + + "ONEGATE_LB1_IP": "10.2.11.86", + "ONEGATE_LB1_PORT": "8686", + + "ONEGATE_LB1_SERVER_HOST": "10.2.11.200", + "ONEGATE_LB1_SERVER_PORT": "8686", + "ONEGATE_LB1_SERVER_WEIGHT": "1" + } + ] + } + } + ] + } + } + } + VNET0 + + load './main.rb'; include Service::LVS + + Service::LVS.const_set :VROUTER_ID, '86' + + allow(Service::LVS).to receive(:addrs_to_nics).and_return({ + '10.2.11.86' => ['eth0'] + }) + output = <<~'DYNAMIC' + virtual_server 10.2.11.86 6969 { + lb_algo rr + lb_kind DR + protocol TCP + + real_server 10.2.11.200 6969 { + weight 1 + PING_CHECK { + retry 4 + } + } + real_server 10.2.11.201 6969 { + weight 2 + PING_CHECK { + retry 4 + } + } + real_server 10.2.11.202 6969 { + weight 3 + PING_CHECK { + retry 4 + } + } + } + virtual_server 10.2.11.86 8686 { + lb_algo rr + lb_kind DR + protocol TCP + + real_server 10.2.11.201 8686 { + weight 2 + PING_CHECK { + retry 4 + } + } + real_server 10.2.11.200 8686 { + weight 1 + PING_CHECK { + retry 4 + } + } + } + DYNAMIC + Dir.mktmpdir do |dir| + lvs_vars = Service::LVS.extract_backends vnets + Service::LVS.render_lvs_conf lvs_vars, basedir: dir + result = File.read "#{dir}/conf.d/lvs.conf" + expect(result.strip).to eq output.strip + end + end + + it 'should render lvs.cfg (dynamic/OneFlow)' do + clear_env + + ENV['ONEAPP_VNF_LB_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_LB_ONEGATE_ENABLED'] = 'YES' + + ENV['ONEAPP_VNF_LB_REFRESH_RATE'] = '' + + ENV['ONEAPP_VNF_LB0_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_LB0_PORT'] = '5432' + ENV['ONEAPP_VNF_LB0_PROTOCOL'] = 'TCP' + ENV['ONEAPP_VNF_LB0_METHOD'] = 'DR' + ENV['ONEAPP_VNF_LB0_TIMEOUT'] = '5' + ENV['ONEAPP_VNF_LB0_SCHEDULER'] = 'rr' + + (vms ||= []) << JSON.parse(<<~'VM0') + { + "VM": { + "NAME": "server_0_(service_23)", + "ID": "435", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "5432", + "ONEGATE_LB0_SERVER_HOST": "10.2.11.202", + "ONEGATE_LB0_SERVER_PORT": "2345", + "ONEGATE_LB0_SERVER_WEIGHT": "1", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.122", + "MAC": "02:00:ac:14:00:7a", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM0 + (vms ||= []) << JSON.parse(<<~'VM1') + { + "VM": { + "NAME": "server_1_(service_23)", + "ID": "436", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "5432", + "ONEGATE_LB0_SERVER_HOST": "10.2.11.203", + "ONEGATE_LB0_SERVER_PORT": "2345", + "ONEGATE_LB0_SERVER_WEIGHT": "2", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.203", + "MAC": "02:00:0a:02:0b:cb", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.123", + "MAC": "02:00:ac:14:00:7b", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM1 + + load './main.rb'; include Service::LVS + + Service::LVS.const_set :VROUTER_ID, nil + + allow(Service::LVS).to receive(:addrs_to_nics).and_return({ + '10.2.11.86' => ['eth0'] + }) + output = <<~'DYNAMIC' + virtual_server 10.2.11.86 5432 { + lb_algo rr + lb_kind DR + protocol TCP + + real_server 10.2.11.202 2345 { + weight 1 + PING_CHECK { + retry 4 + } + } + real_server 10.2.11.203 2345 { + weight 2 + PING_CHECK { + retry 4 + } + } + } + DYNAMIC + Dir.mktmpdir do |dir| + lvs_vars = Service::LVS.extract_backends vms + Service::LVS.render_lvs_conf lvs_vars, basedir: dir + result = File.read "#{dir}/conf.d/lvs.conf" + expect(result.strip).to eq output.strip + end + end +end diff --git a/appliances/VRouter/NAT4/main.rb b/appliances/VRouter/NAT4/main.rb new file mode 100644 index 00000000..93dc78f1 --- /dev/null +++ b/appliances/VRouter/NAT4/main.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' + +module Service +module NAT4 + extend self + + DEPENDS_ON = %w[Service::Failover] + + ONEAPP_VNF_NAT4_ENABLED = env :ONEAPP_VNF_NAT4_ENABLED, 'NO' + + ONEAPP_VNF_NAT4_INTERFACES_OUT = env :ONEAPP_VNF_NAT4_INTERFACES_OUT, '' # nil -> none, empty -> all + + attr_reader :interfaces_out, :mgmt + + @interfaces_out = parse_interfaces ONEAPP_VNF_NAT4_INTERFACES_OUT + @mgmt = detect_mgmt_interfaces + + def install(initdir: '/etc/init.d') + msg :info, 'NAT4::install' + + puts bash 'apk --no-cache add iptables-openrc ruby' + + file "#{initdir}/one-nat4", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__}" + + depend() { + after net firewall keepalived + } + + start() { + $command $command_args -e Service::NAT4.execute 1>>/var/log/one-appliance/one-nat4.log 2>&1 + } + + stop() { + $command $command_args -e Service::NAT4.cleanup 1>>/var/log/one-appliance/one-nat4.log 2>&1 + } + SERVICE + + toggle [:update] + end + + def configure + msg :info, 'NAT4::configure' + + if ONEAPP_VNF_NAT4_ENABLED + toggle [:save, :enable] + else + toggle [:stop, :disable] + end + end + + def execute + msg :info, 'NAT4::execute' + + # Add dedicated NAT4 chain. + bash <<~IPTABLES + iptables -t nat -nL NAT4 || iptables -t nat -N NAT4 + iptables -t nat -C POSTROUTING -j NAT4 || iptables -t nat -A POSTROUTING -j NAT4 + IPTABLES + + interfaces = @interfaces_out.keys - @mgmt + + unless interfaces.empty? + # Add NAT4 rules. + bash ERB.new(<<~IPTABLES, trim_mode: '-').result(binding) + iptables -t nat -F NAT4 + <%- interfaces.each do |nic| -%> + iptables -t nat -A NAT4 -o '<%= nic %>' -j MASQUERADE + <%- end -%> + IPTABLES + end + + toggle [:save, :reload] + end + + def cleanup + msg :info, 'NAT4::cleanup' + + # Clear dedicated NAT4 chain. + bash 'iptables -t nat -F NAT4' + + toggle [:save, :reload] + end + + def toggle(operations) + operations.each do |op| + msg :info, "NAT4::toggle([:#{op}])" + case op + when :save + puts bash 'rc-service iptables save' + when :reload + puts bash 'rc-service iptables reload' + when :enable + puts bash 'rc-update add iptables default' + puts bash 'rc-update add one-nat4 default' + when :disable + puts bash 'rc-update del one-nat4 default ||:' + when :update + puts bash 'rc-update -u' + when :start + puts bash 'rc-service iptables start' + puts bash 'rc-service one-nat4 start' + else + puts bash "rc-service one-nat4 #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'NAT4::bootstrap' + end +end +end diff --git a/appliances/VRouter/Router4/main.rb b/appliances/VRouter/Router4/main.rb new file mode 100644 index 00000000..f8594acd --- /dev/null +++ b/appliances/VRouter/Router4/main.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'erb' +require_relative '../vrouter.rb' + +module Service +module Router4 + extend self + + DEPENDS_ON = %w[Service::Failover] + + VROUTER_ID = env :VROUTER_ID, nil + + ONEAPP_VNF_ROUTER4_ENABLED = env :ONEAPP_VNF_ROUTER4_ENABLED, (VROUTER_ID.nil? ? 'NO' : 'YES') + + ONEAPP_VNF_ROUTER4_INTERFACES = env :ONEAPP_VNF_ROUTER4_INTERFACES, '' # nil -> none, empty -> all + + attr_reader :interfaces, :mgmt + + @interfaces = parse_interfaces ONEAPP_VNF_ROUTER4_INTERFACES + @mgmt = detect_mgmt_interfaces + + def install(initdir: '/etc/init.d') + msg :info, 'Router4::install' + + puts bash 'apk --no-cache add procps ruby' + + file "#{initdir}/one-router4", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__}" + + depend() { + after sysctl net firewall keepalived + } + + start() { + $command $command_args -e Service::Router4.execute 1>>/var/log/one-appliance/one-router4.log 2>&1 + } + + stop() { + $command $command_args -e Service::Router4.cleanup 1>>/var/log/one-appliance/one-router4.log 2>&1 + } + SERVICE + + toggle [:update] + end + + def configure + msg :info, 'Router4::configure' + + if ONEAPP_VNF_ROUTER4_ENABLED + toggle [:enable] + else + toggle [:stop, :disable] + end + end + + def execute(basedir: '/etc/sysctl.d') + msg :info, 'Router4::execute' + + to_enable = @interfaces.keys - @mgmt + to_disable = detect_nics - to_enable + + file "#{basedir}/98-Router4.conf", ERB.new(<<~SYSCTL, trim_mode: '-').result(binding), mode: 'u=rw,go=r', overwrite: true + net.ipv4.ip_forward = 0 + net.ipv4.conf.all.forwarding = 0 + net.ipv4.conf.default.forwarding = 0 + <%- to_enable.each do |nic| -%> + net.ipv4.conf.<%= nic %>.forwarding = 1 + <%- end -%> + <%- to_disable.each do |nic| -%> + net.ipv4.conf.<%= nic %>.forwarding = 0 + <%- end -%> + SYSCTL + + toggle [:reload] + end + + def cleanup(basedir: '/etc/sysctl.d') + msg :info, 'Router4::cleanup' + + to_disable = detect_nics + + file "#{basedir}/98-Router4.conf", ERB.new(<<~SYSCTL, trim_mode: '-').result(binding), mode: 'u=rw,go=r', overwrite: true + net.ipv4.ip_forward = 0 + net.ipv4.conf.all.forwarding = 0 + net.ipv4.conf.default.forwarding = 0 + <%- to_disable.each do |nic| -%> + net.ipv4.conf.<%= nic %>.forwarding = 0 + <%- end -%> + SYSCTL + + toggle [:reload] + end + + def toggle(operations) + operations.each do |op| + msg :info, "Router4::toggle([:#{op}])" + case op + when :enable + puts bash 'rc-update add one-router4 default' + when :disable + puts bash 'rc-update del one-router4 default ||:' + when :update + puts bash 'rc-update -u' + when :reload + puts bash 'sysctl --system' + else + puts bash "rc-service one-router4 #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'Router4::bootstrap' + end +end +end diff --git a/appliances/VRouter/Router4/tests.rb b/appliances/VRouter/Router4/tests.rb new file mode 100644 index 00000000..da5ed670 --- /dev/null +++ b/appliances/VRouter/Router4/tests.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rspec' +require 'tmpdir' + +def clear_env + ENV.delete_if { |name| name.include?('_ROUTER4_') } +end + +RSpec.describe self do + it 'should enable forwarding (legacy)' do + clear_env + + ENV['VROUTER_ID'] = '86' + ENV['ONEAPP_VNF_ROUTER4_INTERFACES'] = 'eth0 eth1 eth2' + + load './main.rb'; include Service::Router4 + + allow(Service::Router4).to receive(:detect_nics).and_return(%w[eth0 eth1 eth2 eth3]) + allow(Service::Router4).to receive(:toggle).and_return(nil) + + output = <<~'SYSCTL' + net.ipv4.ip_forward = 0 + net.ipv4.conf.all.forwarding = 0 + net.ipv4.conf.default.forwarding = 0 + net.ipv4.conf.eth0.forwarding = 1 + net.ipv4.conf.eth1.forwarding = 1 + net.ipv4.conf.eth2.forwarding = 1 + net.ipv4.conf.eth3.forwarding = 0 + SYSCTL + + Dir.mktmpdir do |dir| + Service::Router4.execute basedir: dir + result = File.read "#{dir}/98-Router4.conf" + expect(result.strip).to eq output.strip + end + end + + it 'should enable forwarding' do + clear_env + + ENV['ONEAPP_VNF_ROUTER4_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_ROUTER4_INTERFACES'] = 'eth0 eth1' + + load './main.rb'; include Service::Router4 + + allow(Service::Router4).to receive(:detect_nics).and_return(%w[eth0 eth1 eth2 eth3]) + allow(Service::Router4).to receive(:toggle).and_return(nil) + + output = <<~'SYSCTL' + net.ipv4.ip_forward = 0 + net.ipv4.conf.all.forwarding = 0 + net.ipv4.conf.default.forwarding = 0 + net.ipv4.conf.eth0.forwarding = 1 + net.ipv4.conf.eth1.forwarding = 1 + net.ipv4.conf.eth2.forwarding = 0 + net.ipv4.conf.eth3.forwarding = 0 + SYSCTL + + Dir.mktmpdir do |dir| + Service::Router4.execute basedir: dir + result = File.read "#{dir}/98-Router4.conf" + expect(result.strip).to eq output.strip + end + end + + it 'should disable forwarding' do + clear_env + + ENV['ONEAPP_VNF_ROUTER4_ENABLED'] = 'YES' + ENV['ONEAPP_VNF_ROUTER4_INTERFACES'] = 'eth0 eth1' + + load './main.rb'; include Service::Router4 + + allow(Service::Router4).to receive(:detect_nics).and_return(%w[eth0 eth1 eth2 eth3]) + allow(Service::Router4).to receive(:toggle).and_return(nil) + + output = <<~'SYSCTL' + net.ipv4.ip_forward = 0 + net.ipv4.conf.all.forwarding = 0 + net.ipv4.conf.default.forwarding = 0 + net.ipv4.conf.eth0.forwarding = 0 + net.ipv4.conf.eth1.forwarding = 0 + net.ipv4.conf.eth2.forwarding = 0 + net.ipv4.conf.eth3.forwarding = 0 + SYSCTL + + Dir.mktmpdir do |dir| + Service::Router4.cleanup basedir: dir + result = File.read "#{dir}/98-Router4.conf" + expect(result.strip).to eq output.strip + end + end +end diff --git a/appliances/VRouter/SDNAT4/execute.rb b/appliances/VRouter/SDNAT4/execute.rb new file mode 100644 index 00000000..23b50bdc --- /dev/null +++ b/appliances/VRouter/SDNAT4/execute.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'erb' +require 'json' +require_relative '../vrouter.rb' + +module Service +module SDNAT4 + extend self + + def extract_external(vnets = {}) + vm_map = {} + external = [] + vnets.each do |vn| + next if (vn_id = vn.dig('VNET', 'ID')).nil? + + [vn.dig('VNET', 'AR_POOL', 'AR')].flatten.each do |ar| + ar.dig('LEASES', 'LEASE')&.each do |lease| + vm_map[[lease['VM'], vn_id]] = lease + external << lease if lease['EXTERNAL'] + end + end + end + + ip_map = {} + external.each do |lease| + k = [lease['VM'], lease['PARENT_NETWORK_ID']] + v = vm_map.dig(k, 'IP') + + next if v.nil? + + next unless @subnets.map { |s| s.include?(v) } .any? + + ip_map[lease['IP']] = v + end + + ip_added = [] + document = ip_addr_show 'lo' + document&.dig('addr_info')&.each do |a| + next if a['label'].nil? || a['label'] != 'SDNAT4' + next if a['local'].nil? + + ip_added << a['local'] + end + + to_del = [] + ip_added.each do |ext| + to_del << ext unless ip_map.keys.include?(ext) + end + + to_add = [] + ip_map.each do |ext, _| + to_add << ext unless ip_added.include?(ext) + end + + { external: external, ip_map: ip_map, to_del: to_del, to_add: to_add } + end + + def apply(sdnat4_vars) + # Add SDNAT4 rules. + bash ERB.new(<<~IPTABLES, trim_mode: '-').result(binding) + iptables -t nat -F SNAT4 + iptables -t nat -F DNAT4 + <%- sdnat4_vars[:ip_map].each do |ext, int| -%> + iptables -t nat -A SNAT4 -s '<%= int %>/32' -j SNAT --to-s '<%= ext %>' + iptables -t nat -A DNAT4 -d '<%= ext %>/32' -j DNAT --to-d '<%= int %>' + <%- end -%> + IPTABLES + + # Delete / Add IP aliases. + bash ERB.new(<<~IP, trim_mode: '-').result(binding) + <%- sdnat4_vars[:to_del].each do |ext| -%> + ip address del '<%= ext %>/32' dev lo label SDNAT4 + <%- end -%> + <%- sdnat4_vars[:to_add].each do |ext| -%> + ip address add '<%= ext %>/32' dev lo label SDNAT4 + <%- end -%> + IP + end + + def execute + msg :info, 'SDNAT4::execute' + + prev = [] + + if ONEAPP_VNF_SDNAT4_ENABLED + # Add dedicated SNAT4 chain. + bash <<~IPTABLES + iptables -t nat -nL SNAT4 || iptables -t nat -N SNAT4 + iptables -t nat -C POSTROUTING -j SNAT4 || iptables -t nat -I POSTROUTING 1 -j SNAT4 + IPTABLES + + # Add dedicated DNAT4 chain. + bash <<~IPTABLES + iptables -t nat -nL DNAT4 || iptables -t nat -N DNAT4 + iptables -t nat -C PREROUTING -j DNAT4 || iptables -t nat -I PREROUTING 1 -j DNAT4 + IPTABLES + + toggle [:save] + + loop do + unless (vnets = get_vrouter_vnets).empty? + if prev != (this = extract_external(vnets))[:external] + msg :debug, this + + apply this + + toggle [:save, :reload] + end + + prev = this[:external] + end + + sleep ONEAPP_VNF_SDNAT4_REFRESH_RATE.to_i + end + else + sleep + end + end + + def cleanup + msg :info, 'SDNAT4::cleanup' + + # Clear dedicated SDNAT4 chains. + bash <<~IPTABLES + if iptables -t nat -nL SNAT4; then iptables -t nat -F SNAT4; fi + if iptables -t nat -nL DNAT4; then iptables -t nat -F DNAT4; fi + IPTABLES + + # Clear all SDNAT4-labeled IPs. + document = ip_addr_show 'lo' + document&.dig('addr_info')&.each do |a| + next if a['label'].nil? || a['label'] != 'SDNAT4' + next if a['local'].nil? + + bash "ip address del #{a['local']}/32 dev lo label SDNAT4" + end + + toggle [:save, :reload] + end +end +end diff --git a/appliances/VRouter/SDNAT4/main.rb b/appliances/VRouter/SDNAT4/main.rb new file mode 100644 index 00000000..11a3081a --- /dev/null +++ b/appliances/VRouter/SDNAT4/main.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'ipaddr' +require 'erb' +require_relative '../vrouter.rb' +require_relative 'execute.rb' + +module Service +module SDNAT4 + extend self + + DEPENDS_ON = %w[Service::Failover] + + ONEAPP_VNF_SDNAT4_ENABLED = env :ONEAPP_VNF_SDNAT4_ENABLED, 'NO' + + ONEAPP_VNF_SDNAT4_REFRESH_RATE = env :ONEAPP_VNF_SDNAT4_REFRESH_RATE, '30' + + ONEAPP_VNF_SDNAT4_INTERFACES = env :ONEAPP_VNF_SDNAT4_INTERFACES, nil # nil -> none, empty -> all + + attr_reader :interfaces, :mgmt + + @interfaces = parse_interfaces ONEAPP_VNF_SDNAT4_INTERFACES + @mgmt = detect_mgmt_interfaces + + @subnets = addrs_to_subnets(@interfaces.keys - @mgmt, family: %w[inet]).values.uniq.map { |s| IPAddr.new(s) } + + def install(initdir: '/etc/init.d') + msg :info, 'SDNAT4::install' + + puts bash 'apk --no-cache add iproute2 iptables-openrc ruby' + + file "#{initdir}/one-sdnat4", <<~SERVICE, mode: 'u=rwx,g=rx,o=' + #!/sbin/openrc-run + + source /run/one-context/one_env + + command="/usr/bin/ruby" + command_args="-r /etc/one-appliance/lib/helpers.rb -r #{__FILE__} -e Service::SDNAT4.execute" + + command_background="yes" + pidfile="/run/$RC_SVCNAME.pid" + + output_log="/var/log/one-appliance/one-sdnat4.log" + error_log="/var/log/one-appliance/one-sdnat4.log" + + depend() { + after net firewall keepalived + } + + stop_post() { + $command -r /etc/one-appliance/lib/helpers.rb -r #{__FILE__} -e Service::SDNAT4.cleanup 1>>$output_log 2>>$error_log + } + SERVICE + + toggle [:update] + end + + def configure + msg :info, 'SDNAT4::configure' + + if ONEAPP_VNF_SDNAT4_ENABLED + # Add dedicated SNAT4 chain. + puts bash(<<~IPTABLES) + iptables -t nat -nL SNAT4 || iptables -t nat -N SNAT4 + iptables -t nat -C POSTROUTING -j SNAT4 || iptables -t nat -I POSTROUTING 1 -j SNAT4 + IPTABLES + + # Add dedicated DNAT4 chain. + puts bash(<<~IPTABLES) + iptables -t nat -nL DNAT4 || iptables -t nat -N DNAT4 + iptables -t nat -C PREROUTING -j DNAT4 || iptables -t nat -I PREROUTING 1 -j DNAT4 + IPTABLES + + toggle [:save, :enable] + else + toggle [:stop, :disable] + end + end + + def toggle(operations) + operations.each do |op| + msg :info, "SDNAT4::toggle([:#{op}])" + case op + when :save + puts bash 'rc-service iptables save' + when :reload + puts bash 'rc-service iptables reload' + when :enable + puts bash 'rc-update add iptables default' + puts bash 'rc-update add one-sdnat4 default' + when :disable + puts bash 'rc-update del one-sdnat4 default ||:' + when :update + puts bash 'rc-update -u' + when :start + puts bash 'rc-service iptables start' + puts bash 'rc-service one-sdnat4 start' + else + puts bash "rc-service one-sdnat4 #{op.to_s}" + end + end + end + + def bootstrap + msg :info, 'SDNAT4::bootstrap' + end +end +end diff --git a/appliances/VRouter/SDNAT4/tests.rb b/appliances/VRouter/SDNAT4/tests.rb new file mode 100644 index 00000000..855751cb --- /dev/null +++ b/appliances/VRouter/SDNAT4/tests.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'ipaddr' +require 'rspec' +require 'tmpdir' + +RSpec.describe self do + it 'should extract sdnat4 info from vnets' do + load './main.rb'; include Service::SDNAT4 + + Service::SDNAT4.instance_variable_set(:@subnets, [ + IPAddr.new('10.2.11.0/24'), + IPAddr.new('172.20.0.0/16') + ]) + + allow(Service::SDNAT4).to receive(:ip_addr_show).and_return({ + 'ifname' => 'lo', + 'addr_info' => [ + { 'family' => 'inet', + 'local' => '127.0.0.1', + 'prefixlen' => 8, + 'label' => 'lo' }, + + { 'family' => 'inet', + 'local' => '10.2.11.202', + 'prefixlen' => 32, + 'label' => 'SDNAT4' }, + + # { 'family' => 'inet', + # 'local' => '10.2.11.203', + # 'prefixlen' => 32, + # 'label' => 'SDNAT4' } + ] + }) + + (vnets ||= []) << JSON.parse(<<~'VNET0') + { + "VNET": { + "ID": "0", + "NAME": "service", + "USED_LEASES": "6", + "VROUTERS": { + "ID": [ "35" ] + }, + "PARENT_NETWORK_ID": {}, + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "IP": "10.2.11.200", + "MAC": "02:00:0a:02:0b:c8", + "SIZE": "48", + "TYPE": "IP4", + "MAC_END": "02:00:0a:02:0b:f7", + "IP_END": "10.2.11.247", + "USED_LEASES": "6", + "LEASES": { + "LEASE": [ + { "IP": "10.2.11.200", "MAC": "02:00:0a:02:0b:c8", "VM": "265", "NIC_NAME": "NIC0" }, + { "IP": "10.2.11.201", "MAC": "02:00:0a:02:0b:c9", "VM": "266", "NIC_NAME": "NIC0" }, + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "VM": "267", + "PARENT": "NIC0", + "PARENT_NETWORK_ID": "1", + "EXTERNAL": true, + "NIC_NAME": "NIC0_ALIAS1" + }, + { + "IP": "10.2.11.203", + "MAC": "02:00:0a:02:0b:cb", + "VM": "268", + "PARENT": "NIC0", + "PARENT_NETWORK_ID": "1", + "EXTERNAL": true, + "NIC_NAME": "NIC0_ALIAS1" + }, + { "IP": "10.2.11.204", "MAC": "02:00:0a:02:0b:cc", "VM": "269", "NIC_NAME": "NIC0" }, + { "IP": "10.2.11.205", "MAC": "02:00:0a:02:0b:cd", "VM": "270", "NIC_NAME": "NIC0" } + ] + } + } + ] + }, + "TEMPLATE": { + "NETWORK_ADDRESS": "10.2.11.0", + "NETWORK_MASK": "255.255.255.0", + "GATEWAY": "10.2.11.1", + "DNS": "10.2.11.40" + } + } + } + VNET0 + (vnets ||= []) << JSON.parse(<<~'VNET1') + { + "VNET": { + "ID": "1", + "NAME": "private", + "USED_LEASES": "24", + "VROUTERS": { + "ID": [ "35" ] + }, + "PARENT_NETWORK_ID": {}, + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "IP": "172.20.0.100", + "MAC": "02:00:ac:14:00:64", + "SIZE": "100", + "TYPE": "IP4", + "MAC_END": "02:00:ac:14:00:c7", + "IP_END": "172.20.0.199", + "USED_LEASES": "24", + "LEASES": { + "LEASE": [ + { "IP": "172.20.0.100", "MAC": "02:00:ac:14:00:64", "VNET": "40" }, + { "IP": "172.20.0.101", "MAC": "02:00:ac:14:00:65", "VNET": "40" }, + { "IP": "172.20.0.102", "MAC": "02:00:ac:14:00:66", "VNET": "40" }, + { "IP": "172.20.0.103", "MAC": "02:00:ac:14:00:67", "VNET": "40" }, + { "IP": "172.20.0.104", "MAC": "02:00:ac:14:00:68", "VNET": "40" }, + { "IP": "172.20.0.105", "MAC": "02:00:ac:14:00:69", "VNET": "40" }, + { "IP": "172.20.0.106", "MAC": "02:00:ac:14:00:6a", "VNET": "40" }, + { "IP": "172.20.0.107", "MAC": "02:00:ac:14:00:6b", "VNET": "40" }, + { "IP": "172.20.0.108", "MAC": "02:00:ac:14:00:6c", "VNET": "40" }, + { "IP": "172.20.0.109", "MAC": "02:00:ac:14:00:6d", "VNET": "40" }, + { "IP": "172.20.0.110", "MAC": "02:00:ac:14:00:6e", "VNET": "40" }, + { "IP": "172.20.0.111", "MAC": "02:00:ac:14:00:6f", "VNET": "40" }, + { "IP": "172.20.0.112", "MAC": "02:00:ac:14:00:70", "VNET": "40" }, + { "IP": "172.20.0.113", "MAC": "02:00:ac:14:00:71", "VNET": "40" }, + { "IP": "172.20.0.114", "MAC": "02:00:ac:14:00:72", "VNET": "40" }, + { "IP": "172.20.0.115", "MAC": "02:00:ac:14:00:73", "VNET": "40" }, + { "IP": "172.20.0.116", "MAC": "02:00:ac:14:00:74", "VNET": "40" }, + { "IP": "172.20.0.117", "MAC": "02:00:ac:14:00:75", "VNET": "40" }, + { "IP": "172.20.0.118", "MAC": "02:00:ac:14:00:76", "VNET": "40" }, + { "IP": "172.20.0.119", "MAC": "02:00:ac:14:00:77", "VNET": "40" }, + { "IP": "172.20.0.120", "MAC": "02:00:ac:14:00:78", "VM": "267", "NIC_NAME": "NIC0" }, + { "IP": "172.20.0.121", "MAC": "02:00:ac:14:00:79", "VM": "268", "NIC_NAME": "NIC0" }, + { "IP": "172.20.0.122", "MAC": "02:00:ac:14:00:7a", "VM": "269", "NIC_NAME": "NIC1" }, + { "IP": "172.20.0.123", "MAC": "02:00:ac:14:00:7b", "VM": "270", "NIC_NAME": "NIC1" } + ] + } + } + ] + }, + "TEMPLATE": { + "NETWORK_ADDRESS": "172.20.0.0", + "NETWORK_MASK": "255.255.255.0", + "GATEWAY": "172.20.0.86" + } + } + } + VNET1 + + expect(Service::SDNAT4.extract_external(vnets)).to eq ({ + external: [ + { 'EXTERNAL' => true, + 'IP' => '10.2.11.202', + 'MAC' => '02:00:0a:02:0b:ca', + 'NIC_NAME' => 'NIC0_ALIAS1', + 'PARENT' => 'NIC0', + 'PARENT_NETWORK_ID' => '1', + 'VM' => '267' }, + + { 'EXTERNAL' => true, + 'IP' => '10.2.11.203', + 'MAC' => '02:00:0a:02:0b:cb', + 'NIC_NAME' => 'NIC0_ALIAS1', + 'PARENT' => 'NIC0', + 'PARENT_NETWORK_ID' => '1', + 'VM' => '268' } + ], + ip_map: { '10.2.11.202' => '172.20.0.120', + '10.2.11.203' => '172.20.0.121' }, + to_del: [], + to_add: [ '10.2.11.203' ] + }) + end +end diff --git a/appliances/VRouter/tests.rb b/appliances/VRouter/tests.rb new file mode 100644 index 00000000..d638a6ba --- /dev/null +++ b/appliances/VRouter/tests.rb @@ -0,0 +1,923 @@ +# frozen_string_literal: true + +require 'json' +require 'rspec' +require_relative 'vrouter.rb' + +def clear_env + ENV.delete_if { |name| name.include?('VROUTER_') || name.include?('_VNF_') } +end + +RSpec.describe 'detect_vips' do + it 'should parse legacy variables' do + clear_env + ENV['ETH0_VROUTER_IP'] = '1.2.3.4' + ENV['ONEAPP_VROUTER_ETH0_VIP1'] = '2.3.4.5' + ENV['ONEAPP_VROUTER_ETH1_VIP0'] = '3.4.5.6' + expect(detect_vips).to eq ({ + 'eth0' => { 'ONEAPP_VROUTER_ETH0_VIP0' => '1.2.3.4', + 'ONEAPP_VROUTER_ETH0_VIP1' => '2.3.4.5' }, + 'eth1' => { 'ONEAPP_VROUTER_ETH1_VIP0' => '3.4.5.6' } + }) + end + it 'should parse legacy variables with lower precedence' do + clear_env + ENV['ETH0_VROUTER_IP'] = '1.2.3.4' + ENV['ONEAPP_VROUTER_ETH0_VIP0'] = '2.3.4.5' + ENV['ETH1_VROUTER_IP'] = '3.4.5.6' + expect(detect_vips).to eq ({ + 'eth0' => { 'ONEAPP_VROUTER_ETH0_VIP0' => '2.3.4.5' }, + 'eth1' => { 'ONEAPP_VROUTER_ETH1_VIP0' => '3.4.5.6' } + }) + end +end + +RSpec.describe 'parse_interfaces' do + it 'should return empty interfaces with nil input' do + expect(parse_interfaces(nil)).to be_empty + end + it 'should parse interfaces from a string' do + allow(self).to receive(:detect_nics).and_return([ + 'eth0', + 'eth1', + 'eth2', + 'eth3' + ]) + allow(self).to receive(:addrs_to_nics).and_return({ + '10.0.0.1' => ['eth0', 'eth2'], + '10.0.1.1' => ['eth1'] + }) + tests = [ + [ 'eth7/10.0.0.7 10.0.1.1@53', { 'eth1' => { name: 'eth1', addr: '10.0.1.1', port: '53' }, + 'eth7' => { name: 'eth7', addr: '10.0.0.7', port: nil } } ], + + [ '10.0.1.1@53', { 'eth1' => { name: 'eth1', addr: '10.0.1.1', port: '53' } } ], + + [ '10.0.0.1', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: nil } } ], + + [ 'eth0/10.0.0.1@53 eth1/10.0.1.1@53', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: '53' }, + 'eth1' => { name: 'eth1', addr: '10.0.1.1', port: '53' } } ], + + [ 'eth0/10.0.0.1 eth1/10.0.1.1', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: nil }, + 'eth1' => { name: 'eth1', addr: '10.0.1.1', port: nil } } ], + + [ 'eth0 eth1,eth2;eth3', { 'eth0' => { name: 'eth0', addr: nil, port: nil }, + 'eth1' => { name: 'eth1', addr: nil, port: nil }, + 'eth2' => { name: 'eth2', addr: nil, port: nil }, + 'eth3' => { name: 'eth3', addr: nil, port: nil } } ], + + [ 'eth0/10.0.0.1@53', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: '53' } } ], + + [ 'eth0/10.0.0.1@', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: nil } } ], + + [ 'eth0/10.0.0.1', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: nil } } ], + + [ 'eth0/', { 'eth0' => { name: 'eth0', addr: nil, port: nil } } ], + + [ 'eth0', { 'eth0' => { name: 'eth0', addr: nil, port: nil } } ], + + [ '', { 'eth0' => { name: 'eth0', addr: nil, port: nil }, + 'eth1' => { name: 'eth1', addr: nil, port: nil }, + 'eth2' => { name: 'eth2', addr: nil, port: nil }, + 'eth3' => { name: 'eth3', addr: nil, port: nil } } ] + ] + tests.each do |input, output| + expect(parse_interfaces(input)).to eq output + end + end + it 'should parse interfaces from a string (negation)' do + allow(self).to receive(:detect_nics).and_return([ + 'eth0', + 'eth1', + 'eth2', + 'eth3' + ]) + allow(self).to receive(:addrs_to_nics).and_return({ + '10.0.0.1' => ['eth0', 'eth2'], + '10.0.1.1' => ['eth1'] + }) + tests = [ + [ 'eth0/10.0.0.1@53 eth1 eth2 !10.0.1.1', { 'eth0' => { name: 'eth0', addr: '10.0.0.1', port: '53' }, + 'eth2' => { name: 'eth2', addr: nil, port: nil } } ], + + [ '!eth1 10.0.1.1@53 eth3', { 'eth3' => { name: 'eth3', addr: nil, port: nil } } ], + + [ '10.0.1.1@53 eth3', { 'eth1' => { name: 'eth1', addr: '10.0.1.1', port: '53' }, + 'eth3' => { name: 'eth3', addr: nil, port: nil } } ], + + [ '!10.0.1.1', { 'eth0' => { name: 'eth0', addr: nil, port: nil }, + 'eth2' => { name: 'eth2', addr: nil, port: nil }, + 'eth3' => { name: 'eth3', addr: nil, port: nil } } ], + + [ '!eth0 !eth2', { 'eth1' => { name: 'eth1', addr: nil, port: nil }, + 'eth3' => { name: 'eth3', addr: nil, port: nil } } ], + + [ '!eth0', { 'eth1' => { name: 'eth1', addr: nil, port: nil }, + 'eth2' => { name: 'eth2', addr: nil, port: nil }, + 'eth3' => { name: 'eth3', addr: nil, port: nil } } ] + ] + tests.each do |input, output| + expect(parse_interfaces(input)).to eq output + end + end +end + +RSpec.describe 'render_interface' do + it 'should render interfaces from parts' do + tests = [ + [ { name: 'eth0', addr: nil , port: nil }, + { name: true , addr: false, port: false }, 'eth0' ], + + [ { name: 'eth0', addr: nil , port: nil }, + { name: false , addr: false, port: false }, 'eth0' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: nil }, + { name: true , addr: false , port: false }, 'eth0' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: nil }, + { name: true , addr: true , port: false }, 'eth0/10.0.0.1' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: nil }, + { name: false , addr: true , port: false }, '10.0.0.1' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: '53' }, + { name: true , addr: true , port: true }, 'eth0/10.0.0.1@53' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: '53' }, + { name: true , addr: true , port: false }, 'eth0/10.0.0.1' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: '53' }, + { name: true , addr: false , port: true }, 'eth0@53' ], + + [ { name: 'eth0', addr: '10.0.0.1', port: '53' }, + { name: false , addr: true , port: true }, '10.0.0.1@53' ] + ] + tests.each do |input, options, output| + expect(render_interface(input, **options)).to eq output + end + end +end + +RSpec.describe 'addrs_to_nics' do + it 'should map addrs to nics' do + allow(self).to receive(:ip_addr_list).and_return([ + { 'ifname' => 'eth0', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '10.0.1.1', + 'prefixlen' => 24 }, + { 'family' => 'inet', + 'local' => '10.0.1.2', + 'prefixlen' => 24 } ] }, + + { 'ifname' => 'eth1', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '172.16.1.1', + 'prefixlen' => 16 } ] }, + + { 'ifname' => 'eth2', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '172.18.1.1', + 'prefixlen' => 24 } ] }, + + { 'ifname' => 'eth3', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '172.18.1.1', + 'prefixlen' => 24 } ] } + ]) + tests = [ + [ %w[eth0], { '10.0.1.1' => %w[eth0], + '10.0.1.2' => %w[eth0] } ], + + [ %w[eth1 eth2 eth3], { '172.16.1.1' => %w[eth1], + '172.18.1.1' => %w[eth2 eth3] } ] + ] + tests.each do |input, output| + expect(addrs_to_nics(input)).to eq output + end + end +end + +RSpec.describe 'addrs_to_subnets' do + it 'should extract subnets' do + allow(self).to receive(:ip_addr_list).and_return([ + { 'ifname' => 'eth0', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '10.0.1.1', + 'prefixlen' => 24 }, + { 'family' => 'inet', + 'local' => '10.0.1.2', + 'prefixlen' => 24 } ] }, + + { 'ifname' => 'eth1', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '172.16.1.1', + 'prefixlen' => 16 } ] }, + + { 'ifname' => 'eth2', + 'addr_info' => [ { 'family' => 'inet', + 'local' => '172.18.1.1', + 'prefixlen' => 24 } ] } + ]) + tests = [ + [ %w[eth0], { '10.0.1.1/24' => '10.0.1.0/24', + '10.0.1.2/24' => '10.0.1.0/24' } ], + + [ %w[eth1 eth2], { '172.16.1.1/16' => '172.16.0.0/16', + '172.18.1.1/24' => '172.18.1.0/24' } ] + ] + tests.each do |input, output| + expect(addrs_to_subnets(input)).to eq output + end + end +end + +RSpec.describe 'subnets_to_ranges' do + it 'should convert subnets to ranges' do + tests = [ + [ [ '172.16.0.0/16', '172.18.1.0/24' ], + { '172.16.0.0/16' => '172.16.0.2-172.16.255.254', + '172.18.1.0/24' => '172.18.1.2-172.18.1.254' } ], + + [ [ '2001:db8:1:0::/64', '2001:db8:1:1::/64' ], + { '2001:db8:1:0::/64' => '2001:db8:1::2-2001:db8:1:0:ffff:ffff:ffff:fffe', + '2001:db8:1:1::/64' => '2001:db8:1:1::2-2001:db8:1:1:ffff:ffff:ffff:fffe' } ] + ] + tests.each do |input, output| + expect(subnets_to_ranges(input)).to eq output + end + end +end + +RSpec.describe 'get_service_vms' do + it 'should list all available vms (oneflow)' do + allow(self).to receive(:onegate_service_show).and_return(JSON.parse(<<~'SERVICE_SHOW')) + { + "SERVICE": { + "name": "asd", + "id": "23", + "state": 1, + "roles": [ + { + "name": "server", + "cardinality": 2, + "state": 1, + "nodes": [ + { + "deploy_id": 435, + "running": null, + "vm_info": { + "VM": { + "ID": "435", + "UID": "0", + "GID": "0", + "UNAME": "oneadmin", + "GNAME": "oneadmin", + "NAME": "server_0_(service_23)" + } + } + }, + { + "deploy_id": 436, + "running": null, + "vm_info": { + "VM": { + "ID": "436", + "UID": "0", + "GID": "0", + "UNAME": "oneadmin", + "GNAME": "oneadmin", + "NAME": "server_1_(service_23)" + } + } + } + ] + } + ] + } + } + SERVICE_SHOW + (vms ||= []) << JSON.parse(<<~'VM0_SHOW') + { + "VM": { + "NAME": "server_0_(service_23)", + "ID": "435", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "5432", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.202", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "2345", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.122", + "MAC": "02:00:ac:14:00:7a", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM0_SHOW + (vms ||= []) << JSON.parse(<<~'VM1_SHOW') + { + "VM": { + "NAME": "server_1_(service_23)", + "ID": "436", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_HAPROXY_LB0_IP": "10.2.11.86", + "ONEGATE_HAPROXY_LB0_PORT": "5432", + "ONEGATE_HAPROXY_LB0_SERVER_HOST": "10.2.11.203", + "ONEGATE_HAPROXY_LB0_SERVER_PORT": "2345", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.203", + "MAC": "02:00:0a:02:0b:cb", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.123", + "MAC": "02:00:ac:14:00:7b", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM1_SHOW + + allow(self).to receive(:onegate_vm_show).and_return(*vms) + + expect(get_service_vms).to eq vms + end +end + +RSpec.describe 'get_vrouter_vnets' do + it 'should recursively resolve all viable vnets' do + allow(self).to receive(:onegate_vrouter_show).and_return(JSON.parse(<<~'VROUTER_SHOW')) + { + "VROUTER": { + "NAME": "vrouter", + "ID": "12", + "VMS": { + "ID": [ "115" ] + }, + "TEMPLATE": { + "NIC": [ + { + "NETWORK": "service", + "NETWORK_ID": "0", + "NIC_ID": "0" + }, + { + "NETWORK": "private", + "NETWORK_ID": "1", + "NIC_ID": "1" + } + ], + "TEMPLATE_ID": "74" + } + } + } + VROUTER_SHOW + + (vnets ||= []) << JSON.parse(<<~'SERVICE_VNET_SHOW') + { + "VNET": { + "ID": "0", + "NAME": "service", + "USED_LEASES": "4", + "VROUTERS": { + "ID": [ "12" ] + }, + "PARENT_NETWORK_ID": {}, + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "IP": "10.2.11.200", + "MAC": "02:00:0a:02:0b:c8", + "SIZE": "48", + "TYPE": "IP4", + "MAC_END": "02:00:0a:02:0b:f7", + "IP_END": "10.2.11.247", + "USED_LEASES": "4", + "LEASES": { + "LEASE": [ + { "IP": "10.2.11.200", "MAC": "02:00:0a:02:0b:c8", "VM": "110", "NIC_NAME": "NIC0" }, + { "IP": "10.2.11.201", "MAC": "02:00:0a:02:0b:c9", "VM": "111", "NIC_NAME": "NIC0" }, + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "VM": "113", + "PARENT": "NIC0", + "PARENT_NETWORK_ID": "40", + "EXTERNAL": true, + "NIC_NAME": "NIC0_ALIAS1" + }, + { "IP": "10.2.11.204", "MAC": "02:00:0a:02:0b:cc", "VM": "115", "NIC_NAME": "NIC0" } + ] + } + } + ] + }, + "TEMPLATE": { + "NETWORK_ADDRESS": "10.2.11.0", + "NETWORK_MASK": "255.255.255.0", + "GATEWAY": "10.2.11.1", + "DNS": "10.2.11.40" + } + } + } + SERVICE_VNET_SHOW + (vnets ||= []) << JSON.parse(<<~'PRIVATE_VNET_SHOW') + { + "VNET": { + "ID": "1", + "NAME": "private", + "USED_LEASES": "21", + "VROUTERS": { + "ID": [ "12" ] + }, + "PARENT_NETWORK_ID": {}, + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "IP": "172.20.0.100", + "MAC": "02:00:ac:14:00:64", + "SIZE": "100", + "TYPE": "IP4", + "MAC_END": "02:00:ac:14:00:c7", + "IP_END": "172.20.0.199", + "USED_LEASES": "21", + "LEASES": { + "LEASE": [ + { "IP": "172.20.0.100", "MAC": "02:00:ac:14:00:64", "VNET": "40" }, + { "IP": "172.20.0.101", "MAC": "02:00:ac:14:00:65", "VNET": "40" }, + { "IP": "172.20.0.102", "MAC": "02:00:ac:14:00:66", "VNET": "40" }, + { "IP": "172.20.0.103", "MAC": "02:00:ac:14:00:67", "VNET": "40" }, + { "IP": "172.20.0.104", "MAC": "02:00:ac:14:00:68", "VNET": "40" }, + { "IP": "172.20.0.105", "MAC": "02:00:ac:14:00:69", "VNET": "40" }, + { "IP": "172.20.0.106", "MAC": "02:00:ac:14:00:6a", "VNET": "40" }, + { "IP": "172.20.0.107", "MAC": "02:00:ac:14:00:6b", "VNET": "40" }, + { "IP": "172.20.0.108", "MAC": "02:00:ac:14:00:6c", "VNET": "40" }, + { "IP": "172.20.0.109", "MAC": "02:00:ac:14:00:6d", "VNET": "40" }, + { "IP": "172.20.0.110", "MAC": "02:00:ac:14:00:6e", "VNET": "40" }, + { "IP": "172.20.0.111", "MAC": "02:00:ac:14:00:6f", "VNET": "40" }, + { "IP": "172.20.0.112", "MAC": "02:00:ac:14:00:70", "VNET": "40" }, + { "IP": "172.20.0.113", "MAC": "02:00:ac:14:00:71", "VNET": "40" }, + { "IP": "172.20.0.114", "MAC": "02:00:ac:14:00:72", "VNET": "40" }, + { "IP": "172.20.0.115", "MAC": "02:00:ac:14:00:73", "VNET": "40" }, + { "IP": "172.20.0.116", "MAC": "02:00:ac:14:00:74", "VNET": "40" }, + { "IP": "172.20.0.117", "MAC": "02:00:ac:14:00:75", "VNET": "40" }, + { "IP": "172.20.0.118", "MAC": "02:00:ac:14:00:76", "VNET": "40" }, + { "IP": "172.20.0.119", "MAC": "02:00:ac:14:00:77", "VNET": "40" }, + { "IP": "172.20.0.121", "MAC": "02:00:ac:14:00:79", "VM": "115", "NIC_NAME": "NIC1" } + ] + } + } + ] + }, + "TEMPLATE": { + "NETWORK_ADDRESS": "172.20.0.0", + "NETWORK_MASK": "255.255.255.0", + "GATEWAY": "172.20.0.86" + } + } + } + PRIVATE_VNET_SHOW + (vnets ||= []) << JSON.parse(<<~'RESERVATION_VNET_SHOW') + { + "VNET": { + "ID": "40", + "NAME": "reservation", + "USED_LEASES": "2", + "VROUTERS": { + "ID": [] + }, + "PARENT_NETWORK_ID": "1", + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "IP": "172.20.0.100", + "MAC": "02:00:ac:14:00:64", + "PARENT_NETWORK_AR_ID": "0", + "SIZE": "20", + "TYPE": "IP4", + "MAC_END": "02:00:ac:14:00:77", + "IP_END": "172.20.0.119", + "USED_LEASES": "2", + "LEASES": { + "LEASE": [ + { "IP": "172.20.0.100", "MAC": "02:00:ac:14:00:64", "VM": "112", "NIC_NAME": "NIC0" }, + { "IP": "172.20.0.101", "MAC": "02:00:ac:14:00:65", "VM": "113", "NIC_NAME": "NIC0" } + ] + } + } + ] + }, + "TEMPLATE": { + "NETWORK_ADDRESS": "172.20.0.0", + "NETWORK_MASK": "255.255.255.0", + "GATEWAY": "172.20.0.86" + } + } + } + RESERVATION_VNET_SHOW + + allow(self).to receive(:onegate_vnet_show).and_return(*vnets) + + expect(get_vrouter_vnets).to eq vnets + end +end + +RSpec.describe 'backends.from_env' do + it 'should correctly extract backends from env vars' do + clear_env + + ENV['ONEAPP_VNF_LB0_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_LB0_PORT'] = '6969' + ENV['ONEAPP_VNF_LB0_PROTOCOL'] = 'TCP' + + ENV['ONEAPP_VNF_LB0_SERVER0_HOST'] = 'asd0' + ENV['ONEAPP_VNF_LB0_SERVER0_PORT'] = '1234' + ENV['ONEAPP_VNF_LB0_SERVER0_WEIGHT'] = '1' + + ENV['ONEAPP_VNF_LB0_SERVER1_HOST'] = 'asd1' + ENV['ONEAPP_VNF_LB0_SERVER1_PORT'] = '1234' + ENV['ONEAPP_VNF_LB0_SERVER1_WEIGHT'] = '2' + + ENV['ONEAPP_VNF_LB0_SERVER2_HOST'] = 'asd2' + ENV['ONEAPP_VNF_LB0_SERVER2_PORT'] = '1234' + ENV['ONEAPP_VNF_LB0_SERVER2_WEIGHT'] = '3' + + ENV['ONEAPP_VNF_LB1_IP'] = '10.2.11.86' + ENV['ONEAPP_VNF_LB1_PORT'] = '8686' + ENV['ONEAPP_VNF_LB1_PROTOCOL'] = 'TCP' + + ENV['ONEAPP_VNF_LB1_SERVER0_HOST'] = 'asd0' + ENV['ONEAPP_VNF_LB1_SERVER0_PORT'] = '4321' + ENV['ONEAPP_VNF_LB1_SERVER0_WEIGHT'] = '1' + + ENV['ONEAPP_VNF_LB1_SERVER1_HOST'] = 'asd1' + ENV['ONEAPP_VNF_LB1_SERVER1_PORT'] = '4321' + ENV['ONEAPP_VNF_LB1_SERVER1_WEIGHT'] = '2' + + expect(backends.from_env).to eq ({ + by_endpoint: { + [ 0, '10.2.11.86', '6969' ] => + { [ 'asd0', '1234' ] => { host: 'asd0', port: '1234', weight: '1' }, + [ 'asd1', '1234' ] => { host: 'asd1', port: '1234', weight: '2' }, + [ 'asd2', '1234' ] => { host: 'asd2', port: '1234', weight: '3' } }, + + [ 1, '10.2.11.86', '8686' ] => + { [ 'asd0', '4321' ] => { host: 'asd0', port: '4321', weight: '1' }, + [ 'asd1', '4321' ] => { host: 'asd1', port: '4321', weight: '2' } } }, + + options: { 0 => { ip: '10.2.11.86', port: '6969', protocol: 'TCP' }, + 1 => { ip: '10.2.11.86', port: '8686', protocol: 'TCP' } } + }) + end +end + +RSpec.describe 'backends.from_vnets' do + it 'should correctly extract backends from vnets' do + (vnets ||= []) << JSON.parse(<<~'VNET0') + { + "VNET": { + "ID": "0", + "AR_POOL": { + "AR": [ + { + "AR_ID": "0", + "LEASES": { + "LEASE": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "6969", + "ONEGATE_LB0_PROTOCOL": "TCP", + "ONEGATE_LB0_SERVER_HOST": "asd2", + "ONEGATE_LB0_SERVER_PORT": "1234", + "ONEGATE_LB0_SERVER_WEIGHT": "3" + }, + { + "IP": "10.2.11.201", + "MAC": "02:00:0a:02:0b:c9", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "6969", + "ONEGATE_LB0_PROTOCOL": "TCP", + "ONEGATE_LB0_SERVER_HOST": "asd1", + "ONEGATE_LB0_SERVER_PORT": "1234", + "ONEGATE_LB0_SERVER_WEIGHT": "2", + + "ONEGATE_LB1_IP": "10.2.11.86", + "ONEGATE_LB1_PORT": "8686", + "ONEGATE_LB1_PROTOCOL": "TCP", + "ONEGATE_LB1_SERVER_HOST": "asd1", + "ONEGATE_LB1_SERVER_PORT": "4321", + "ONEGATE_LB1_SERVER_WEIGHT": "2" + }, + { + "IP": "10.2.11.200", + "MAC": "02:00:0a:02:0b:c8", + "VM": "167", + "NIC_NAME": "NIC0", + "BACKEND": "YES", + + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "6969", + "ONEGATE_LB0_PROTOCOL": "TCP", + "ONEGATE_LB0_SERVER_HOST": "asd0", + "ONEGATE_LB0_SERVER_PORT": "1234", + "ONEGATE_LB0_SERVER_WEIGHT": "1", + + "ONEGATE_LB1_IP": "10.2.11.86", + "ONEGATE_LB1_PORT": "8686", + "ONEGATE_LB1_PROTOCOL": "TCP", + "ONEGATE_LB1_SERVER_HOST": "asd0", + "ONEGATE_LB1_SERVER_PORT": "4321", + "ONEGATE_LB1_SERVER_WEIGHT": "1" + } + ] + } + } + ] + } + } + } + VNET0 + expect(backends.from_vnets(vnets)).to eq ({ + by_endpoint: { + [ 0, '10.2.11.86', '6969' ] => + { [ 'asd0', '1234' ] => { host: 'asd0', port: '1234', weight: '1' }, + [ 'asd1', '1234' ] => { host: 'asd1', port: '1234', weight: '2' }, + [ 'asd2', '1234' ] => { host: 'asd2', port: '1234', weight: '3' } }, + + [ 1, '10.2.11.86', '8686' ] => + { [ 'asd0', '4321' ] => { host: 'asd0', port: '4321', weight: '1' }, + [ 'asd1', '4321' ] => { host: 'asd1', port: '4321', weight: '2' } } }, + + options: { 0 => { ip: '10.2.11.86', port: '6969', protocol: 'TCP' }, + 1 => { ip: '10.2.11.86', port: '8686', protocol: 'TCP' } } + }) + end +end + +RSpec.describe 'backends.from_vms' do + it 'should correctly extract backends from vms (oneflow)' do + (vms ||= []) << JSON.parse(<<~'VM0') + { + "VM": { + "NAME": "server_0_(service_23)", + "ID": "435", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "5432", + "ONEGATE_LB0_SERVER_HOST": "10.2.11.202", + "ONEGATE_LB0_SERVER_PORT": "2345", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.202", + "MAC": "02:00:0a:02:0b:ca", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.122", + "MAC": "02:00:ac:14:00:7a", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM0 + (vms ||= []) << JSON.parse(<<~'VM1') + { + "VM": { + "NAME": "server_1_(service_23)", + "ID": "436", + "STATE": "3", + "LCM_STATE": "3", + "USER_TEMPLATE": { + "HOT_RESIZE": { + "CPU_HOT_ADD_ENABLED": "NO", + "MEMORY_HOT_ADD_ENABLED": "NO" + }, + "LOGO": "images/logos/linux.png", + "LXD_SECURITY_PRIVILEGED": "true", + "MEMORY_UNIT_COST": "MB", + "ONEGATE_LB0_IP": "10.2.11.86", + "ONEGATE_LB0_PORT": "5432", + "ONEGATE_LB0_SERVER_HOST": "10.2.11.203", + "ONEGATE_LB0_SERVER_PORT": "2345", + "ROLE_NAME": "server", + "SERVICE_ID": "23" + }, + "TEMPLATE": { + "NIC": [ + { + "IP": "10.2.11.203", + "MAC": "02:00:0a:02:0b:cb", + "NAME": "_NIC0", + "NETWORK": "service" + }, + { + "IP": "172.20.0.123", + "MAC": "02:00:ac:14:00:7b", + "NAME": "_NIC1", + "NETWORK": "private" + } + ], + "NIC_ALIAS": [] + } + } + } + VM1 + + expect(backends.from_vms(vms)).to eq ({ + by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' }, + [ '10.2.11.203', '2345' ] => { host: '10.2.11.203', port: '2345' } } }, + + options: { 0 => { ip: '10.2.11.86', port: '5432' } } + }) + end +end + +RSpec.describe 'backends.intersect' do + it 'should extract only common endpoints (host/port pairs)' do + tests = [ + [ + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '1111'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '2345' } } }, + + { by_endpoint: {}, options: {} } + ], + [ + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } } + ], + [ + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } }, + [ 1, '10.2.11.86', '1111'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' }, + 1 => { ip: '10.2.11.86', port: '1111' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } } + ], + [ + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } }, + [ 1, '10.2.11.86', '1111'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' }, + 1 => { ip: '10.2.11.86', port: '1111' } } }, + + { by_endpoint: { + [ 0, '10.2.11.86', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } } }, + options: { 0 => { ip: '10.2.11.86', port: '5432' } } } + ] + ] + tests.each do |a, b, output| + expect(backends.intersect(a, b)).to eq output + end + end +end + +RSpec.describe 'backends.resolve_vips' do + it 'should replace vip placeholders with existing vip ip addresses' do + tests = [ + [ + { 'eth0' => { 'ONEAPP_VROUTER_ETH0_VIP0' => '1.2.3.4', + 'ONEAPP_VROUTER_ETH0_VIP1' => '2.3.4.5' }, + 'eth1' => { 'ONEAPP_VROUTER_ETH1_VIP0' => '3.4.5.6' } }, + + { by_endpoint: { + [ 0, '', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } }, + [ 1, '', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } }, + [ 2, '4.5.6.7', '1111'] => + { [ '10.2.11.203', '2222' ] => { host: '10.2.11.203', port: '2222' } } }, + options: { 0 => { ip: '', port: '5432' }, + 1 => { ip: '', port: '5432' }, + 2 => { ip: '4.5.6.7', port: '1111' } } }, + + { by_endpoint: { + [ 0, '2.3.4.5', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } }, + [ 1, '3.4.5.6', '5432'] => + { [ '10.2.11.202', '2345' ] => { host: '10.2.11.202', port: '2345' } }, + [ 2, '4.5.6.7', '1111'] => + { [ '10.2.11.203', '2222' ] => { host: '10.2.11.203', port: '2222' } } }, + options: { 0 => { ip: '2.3.4.5', port: '5432' }, + 1 => { ip: '3.4.5.6', port: '5432' }, + 2 => { ip: '4.5.6.7', port: '1111' } } } + ] + ] + tests.each do |vips, b, output| + expect(backends.resolve_vips(b, vips)).to eq output + end + end +end diff --git a/appliances/VRouter/tests.sh b/appliances/VRouter/tests.sh new file mode 100755 index 00000000..819172f9 --- /dev/null +++ b/appliances/VRouter/tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -eu -o pipefail; shopt -qs failglob + +find . -type f -name 'tests.rb' | while read FILE; do + (cd $(dirname "$FILE")/ && echo ">> $FILE <<" && rspec $(basename "$FILE")) +done diff --git a/appliances/VRouter/vrouter.rb b/appliances/VRouter/vrouter.rb new file mode 100644 index 00000000..8e90a868 --- /dev/null +++ b/appliances/VRouter/vrouter.rb @@ -0,0 +1,399 @@ +# frozen_string_literal: true + +require 'ipaddr' +require 'json' + +begin + require '/etc/one-appliance/lib/helpers.rb' +rescue LoadError + require_relative '../lib/helpers.rb' +end + +def ip_link_list + stdout = bash 'ip --json link list', terminate: false + JSON.parse(stdout) +end + +def ip_link_show(nic) + stdout = bash "ip --json link show '#{nic}'", terminate: false + JSON.parse(stdout).first +end + +def ip_addr_list + stdout = bash 'ip --json addr list', terminate: false + JSON.parse(stdout) +end + +def ip_addr_show(nic) + stdout = bash "ip --json addr show '#{nic}'", terminate: false + JSON.parse(stdout).first +end + +def detect_nics(items = ip_link_list, pattern: /^eth\d+$/) + items.select { |nic| nic['ifname'] =~ pattern } + .map { |nic| nic['ifname'] } +end + +def detect_vips + ENV.each_with_object({}) do |(name, v), acc| + next if v.empty? + case name + when /^ETH(\d+)_VROUTER_IP$/ + acc["eth#{$1}"] ||= {} + acc["eth#{$1}"]["ONEAPP_VROUTER_ETH#{$1}_VIP0"] ||= v + when /^ONEAPP_VROUTER_ETH(\d+)_VIP\d+$/ + acc["eth#{$1}"] ||= {} + acc["eth#{$1}"][name] = v + end + end +end + +def detect_mgmt_interfaces + ENV.keys.select do |name| + name.start_with?('ETH') && name.end_with?('_VROUTER_MANAGEMENT') && env(name, 'NO') + end.map do |name| + name.split('_').first.downcase + end +end + +def parse_interfaces(interfaces, pattern: /^[!]?(lo|eth\d+)$/) + return {} if interfaces.nil? + + addrs = nil + + excluded, included = [], [] + + interfaces.split(%r{[ ,;]}).map(&:strip).compact.each do |interface| + if interface.start_with?(%[!]) + excluded << interface.delete_prefix(%[!]) if interface.size > 1 + else + included << interface if interface.size > 0 + end + end + + included = detect_nics if included.empty? + + excluded, included = [excluded, included].map do |collection| + collection.each_with_object({}) do |interface, acc| + parts = { name: nil, addr: nil, port: nil } + + interface.split(%r{(?=#{pattern.source}|[/@])}).each do |p| + case p + when pattern then parts[:name] = p + when %r{^/} then parts[:addr] = p.delete_prefix(%[/]) if p.size > 1 + when %r{^@} then parts[:port] = p.delete_prefix(%[@]) if p.size > 1 + else parts[:addr] = p + end + end + + if parts[:name].nil? + next if parts[:addr].nil? + + addrs ||= addrs_to_nics + parts[:name] = addrs[parts[:addr].downcase]&.first + end + + acc[parts[:name]] = parts + end + end + + included.select { |name, _| !name.nil? && !excluded.include?(name) } +end + +def render_interface(parts, name: false, addr: true, port: true) + tmp = [] + + tmp << parts[:name] if name || parts[:addr].nil? + + if addr && !parts[:addr].nil? + tmp << %[/] if name + tmp << parts[:addr] + end + + if port && !parts[:port].nil? + tmp << %[@] + tmp << parts[:port] + end + + tmp.join +end + +def addrs_to_nics(interfaces = detect_nics, family: %w[inet inet6]) + ip_addr_list.each_with_object({}) do |addr, out| + next if addr['ifname'].nil? + next unless interfaces.include?(addr['ifname']) + + next if addr['addr_info'].nil? + + addr['addr_info'].each do |info| + next if info['family'].nil? + next unless family.include?(info['family'].downcase) + + next if info['local'].nil? + + (out[info['local']] ||= []) << addr['ifname'] + end + end +end + +def addrs_to_subnets(interfaces = detect_nics, family: %w[inet inet6]) + ip_addr_list.each_with_object({}) do |addr, out| + next if addr['ifname'].nil? + next unless interfaces.include?(addr['ifname']) + + next if addr['addr_info'].nil? + + addr['addr_info'].each do |info| + next if info['family'].nil? + next unless family.include?(info['family']) + + next if info['local'].nil? + + key = %[#{info['local']}/#{info['prefixlen']}] + + subnet = IPAddr.new(key) + + out[key] = %[#{subnet}/#{subnet.prefix}] + end + end +end + +def subnets_to_ranges(subnets = addrs_to_subnets.values) + subnets.each_with_object({}) do |subnet, out| + addr = IPAddr.new(subnet) + range = addr.to_range + out[subnet] = [ + # Skip the network and the first usable address. + IPAddr.new(range.first.to_i + 2, addr.family).to_s, + # Skip the last address (broadcast). + IPAddr.new(range.last.to_i - 1, addr.family).to_s + ].join('-') + end +end + +def onegate_vrouter_show + stdout = bash 'onegate vrouter show --json --extended', terminate: false + JSON.parse(stdout) +rescue StandardError => e + msg :error, e.full_message + nil +end + +def onegate_vnet_show(network_id) + stdout = bash "onegate vnet show --json --extended '#{network_id}'", terminate: false + JSON.parse(stdout) +rescue StandardError => e + msg :error, e.full_message + nil +end + +def get_vrouter_vnets + return [] if (document = onegate_vrouter_show).nil? + return [] if (nics = document.dig('VROUTER', 'TEMPLATE', 'NIC')).nil? + + initial_network_ids = nics.map { |nic| nic['NETWORK_ID'] } + .compact + .uniq + + return [] if initial_network_ids.empty? + + def recurse(network_ids) + network_ids.each_with_object([]) do |network_id, vnets| + next if (vnet = onegate_vnet_show(network_id)).nil? + + vnets << vnet + + parent_network_id = vnet['PARENT_NETWORK_ID'] + + vnets << recurse([parent_network_id]) unless parent_network_id.nil? + + next if (ars = vnet.dig('VNET', 'AR_POOL', 'AR')).nil? + + ars.each do |ar| + next if (leases = ar.dig('LEASES', 'LEASE')).nil? + + parent_network_ids = leases.map { |lease| lease['VNET'] } + .compact + .uniq + + next if parent_network_ids.empty? + + vnets << recurse(parent_network_ids) + end + end.flatten.uniq + end + + recurse(initial_network_ids) +end + +def onegate_service_show + stdout = bash 'onegate service show --json', terminate: false + JSON.parse(stdout) +rescue StandardError => e + msg :error, e.full_message + nil +end + +def onegate_vm_show(vm_id) + stdout = bash "onegate vm show --json '#{vm_id}'", terminate: false + JSON.parse(stdout) +rescue StandardError => e + msg :error, e.full_message + nil +end + +def get_service_vms # OneFlow + return [] if (document = onegate_service_show).nil? + return [] if (roles = document.dig('SERVICE', 'roles')).nil? + + roles.each_with_object([]) do |role, acc| + next if (nodes = role.dig('nodes')).nil? + + nodes.each do |node| + next if (vm_id = node.dig('vm_info', 'VM', 'ID')).nil? + + acc << vm_id + end + end.uniq.each_with_object([]) do |vm_id, acc| + next if (vm = onegate_vm_show(vm_id)).nil? + + acc << vm + end +end + +def backends + def parse_static(names, prefix) + names.each_with_object({}) do |name, acc| + case name + when /^#{prefix}(\d+)_(IP|PORT|PROTOCOL|METHOD|TIMEOUT|SCHEDULER)$/ + lb_idx, opt = $1.to_i, $2 + key = lb_idx + acc[:options] ||= {} + acc[:options][key] ||= {} + acc[:options][key][opt.downcase.to_sym] = env(name, '') + when /^#{prefix}(\d+)_SERVER(\d+)_(HOST|PORT|WEIGHT|ULIMIT|LLIMIT)$/ + lb_idx, vm_idx, opt = $1.to_i, $2.to_i, $3 + key = [lb_idx, vm_idx] + acc[:by_indices] ||= {} + acc[:by_indices][key] ||= {} + acc[:by_indices][key][opt.downcase.to_sym] = env(name, '') + end + end.then do |doc| + doc[:by_indices]&.each do |(lb_idx, _), v| + key1 = [lb_idx, doc[:options][lb_idx][:ip], doc[:options][lb_idx][:port]] + next unless key1.all? + + key2 = [v[:host], v[:port]] + next unless key2.all? + + doc[:by_endpoint] ||= {} + doc[:by_endpoint][key1] ||= {} + doc[:by_endpoint][key1][key2] = v + end + doc.delete(:by_indices) + doc + end + end + + def parse_dynamic(objects, prefix) + objects.each_with_object({}) do |(name, v), acc| + case name + when /^#{prefix}(\d+)_(IP|PORT|PROTOCOL)$/ + lb_idx, opt = $1.to_i, $2 + key = lb_idx + acc[:options] ||= {} + acc[:options][key] ||= {} + acc[:options][key][opt.downcase.to_sym] = v + when /^#{prefix}(\d+)_SERVER_(HOST|PORT|WEIGHT|ULIMIT|LLIMIT)$/ + lb_idx, opt = $1.to_i, $2 + key = lb_idx + acc[:by_index] ||= {} + acc[:by_index][key] ||= {} + acc[:by_index][key][opt.downcase.to_sym] = v + end + end.then do |doc| + doc[:by_index]&.each do |lb_idx, v| + key1 = [lb_idx, doc[:options][lb_idx][:ip], doc[:options][lb_idx][:port]] + next unless key1.all? + + key2 = [v[:host], v[:port]] + next unless key2.all? + + doc[:by_endpoint] ||= {} + doc[:by_endpoint][key1] ||= {} + doc[:by_endpoint][key1][key2] = v + end + doc.delete(:by_index) + doc + end + end + + def from_env(prefix: 'ONEAPP_VNF_LB') # also 'ONEAPP_HAPROXY_VNF_LB' + parse_static(ENV.keys, prefix) + end + + def from_vnets(vnets, prefix: 'ONEGATE_LB') # also 'ONEGATE_HAPROXY_LB' + vnets.each_with_object({}) do |vnet, acc| + next if (ars = vnet.dig('VNET', 'AR_POOL', 'AR')).nil? + + ars.each do |ar| + next if (leases = ar.dig('LEASES', 'LEASE')).nil? + + leases.each do |lease| + next if lease['BACKEND'] != 'YES' + + hashmap.combine! acc, parse_dynamic(lease, prefix) + end + end + end + end + + def from_vms(vms, prefix: 'ONEGATE_LB') # also 'ONEGATE_HAPROXY_LB' + vms.each_with_object({}) do |vm, acc| + next if (user_template = vm.dig('VM', 'USER_TEMPLATE')).nil? + + hashmap.combine! acc, parse_dynamic(user_template, prefix) + end + end + + def intersect(a, b) + a[:by_endpoint] ||= {} + a[:options] ||= {} + + b[:by_endpoint] ||= {} + b[:options] ||= {} + + a_keys = a[:options].map { |lb_idx, opt| [lb_idx, opt[:ip], opt[:port]] } + b_keys = b[:options].map { |lb_idx, opt| [lb_idx, opt[:ip], opt[:port]] } + + keys = a_keys.intersection(b_keys) + + { by_endpoint: b[:by_endpoint].slice(*keys), + options: b[:options].slice(*keys.map { |key| key[0] }) } + end + + def resolve_vips(a, vips = detect_vips) + vips = vips.values.each_with_object({}) do |h, acc| + hashmap.combine! acc, h + end + + def interpolate(ip, vips) + if ip =~ /^<([A-Z_0-9]+)>$/ && !vips[$1].nil? + vips[$1].split('/')[0] # remove the CIDR prefix if present + else + ip + end + end + + { + by_endpoint: a[:by_endpoint].to_h do |(lb_idx, ip, port), v| + [ [lb_idx, interpolate(ip, vips), port], v ] + end, + + options: a[:options].to_h do |lb_idx, v| + v[:ip] = interpolate(v[:ip], vips) + [ lb_idx, v ] + end + } + end +end diff --git a/appliances/lib/helpers.rb b/appliances/lib/helpers.rb new file mode 100644 index 00000000..d22003a9 --- /dev/null +++ b/appliances/lib/helpers.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'base64' +require 'fileutils' +require 'ipaddr' +require 'json' +require 'logger' +require 'open3' +require 'socket' + +LOGGER_STDOUT = Logger.new(STDOUT) +LOGGER_STDERR = Logger.new(STDERR) + +LOGGERS = { + info: LOGGER_STDOUT.method(:info), + debug: LOGGER_STDERR.method(:debug), + warn: LOGGER_STDERR.method(:warn), + error: LOGGER_STDERR.method(:error) +}.freeze + +def msg(level, string) + LOGGERS[level].call string +end + +def env(name, default) + value = ENV.fetch name.to_s, '' + value = value.empty? ? default : value + value = %w[YES 1].include?(value.upcase) if default.instance_of?(String) && %w[YES NO].include?(default.upcase) + value +end + +def load_env(path = '/run/one-context/one_env') + File.read(path).lines.each do |line| + line.strip! + next if line.empty? + + line.delete_prefix!('export ') + + k, v = line.split('=', 2) + next if v.nil? + + ENV[k] = v.undump + end +end + +def slurp(path) + Base64.encode64(File.read(path)).lines.map(&:strip).join +end + +def file(path, content, owner: nil, group: nil, mode: 'u=rw,go=r', overwrite: false) + return if !overwrite && File.exist?(path) + + FileUtils.mkdir_p File.dirname path + + File.write path, content + + FileUtils.chown owner, group, path unless owner.nil? || group.nil? + + FileUtils.chmod mode, path +end + +def bash(script, chomp: false, terminate: false) + command = 'exec /bin/bash --login -s' + + stdin_data = <<~SCRIPT + set -o errexit -o nounset -o pipefail + set -x + #{script} + SCRIPT + + stdout, stderr, status = Open3.capture3 command, stdin_data: stdin_data + unless status.exitstatus.zero? + error_message = "#{status.exitstatus}: #{stderr}" + msg :error, error_message + + raise error_message unless terminate + + exit status.exitstatus + end + + chomp ? stdout.chomp : stdout +end + +def ipv4?(string) + string.is_a?(String) && IPAddr.new(string) ? true : false +rescue IPAddr::InvalidAddressError + false +end + +def integer?(string) + Integer(string) ? true : false +rescue ArgumentError + false +end + +alias port? integer? + +def tcp_port_open?(ipv4, port, seconds = 5) + # > If a block is given, the block is called with the socket. + # > The value of the block is returned. + # > The socket is closed when this method returns. + Socket.tcp(ipv4, port, connect_timeout: seconds) {} + true +rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT + false +end + +def hashmap + def recurse(a, b, g) + return a.method(g.next).call(b) { |_, a, b| recurse(a, b, g) } if a.is_a?(Hash) && b.is_a?(Hash) + return b + end + + # USAGE: c = hashmap.combine a, b + def combine(a, b) + recurse(a, b, Enumerator.new { |y| loop { y << :merge } }) + end + + # USAGE: hashmap.combine! a, b + def combine!(a, b) + recurse(a, b, Enumerator.new { |y| y << :merge!; loop { y << :merge } }) + end +end + +def sorted_deps(deps) + # NOTE: This doesn't handle circular dependencies. + + # Work with string keys only. + d = deps.to_h { |k, v| [k.to_s, v.map(&:to_s)] } + + def recurse(d, x, level = 0) + # The distance is at least the same as the current level. + distance = level + + # Recurse down each branch and record the longest distance to the root. + d[x].each { |y| distance = [distance, recurse(d, y, level + 1)].max } + + distance + end + + deps.keys.map { |k| [k, recurse(d, k.to_s)] } # compute the longest distance + .sort_by(&:last) # sort by the distance + .map(&:first) # return sorted keys (original) +end + +# install|configure|bootstrap started|success|failure +def set_motd(step, status, path = '/etc/motd') + header_txt = <<~'HEADER' + . + ___ _ __ ___ + / _ \ | '_ \ / _ \ OpenNebula Service Appliance + | (_) || | | || __/ + \___/ |_| |_| \___| + + HEADER + + step_txt = \ + case step.to_sym + when :install then '1/3 Installation' + when :configure then '2/3 Configuration' + when :bootstrap then '3/3 Bootstrap' + end + + status_txt = \ + case status.to_sym + when :started then <<~STARTED + #{header_txt} + #{step_txt} step is in progress... + + * * * * * * * * + * PLEASE WAIT * + * * * * * * * * + + STARTED + when :success then if step.to_sym == :bootstrap + <<~SUCCESS + #{header_txt} + All set and ready to serve 8) + + SUCCESS + else + <<~SUCCESS + #{header_txt} + #{step_txt} step was successfull. + + SUCCESS + end + when :failure then <<~FAILURE + #{header_txt} + #{step_txt} step failed. + + * * * * * * * * * * + * APPLIANCE ERROR * + * * * * * * * * * * + + Read documentation and try to redeploy! + + FAILURE + end + + file path, status_txt.delete_prefix('.'), mode: 'u=rw,go=r', overwrite: true +end + +# install|configure|bootstrap|success|failure +def set_status(status, path = '/etc/one-appliance/status') + case status.to_sym + when :install, :configure, :bootstrap + file path, <<~STATUS, mode: 'u=rw,go=r', overwrite: true + #{status.to_s}_started + STATUS + set_motd status, :started + when :success, :failure + step = File.open(path, &:gets).strip.split('_').first + file path, <<~STATUS, mode: 'u=rw,go=r', overwrite: true + #{step}_#{status.to_s} + STATUS + set_motd step, status + end +end diff --git a/appliances/lib/tests.rb b/appliances/lib/tests.rb new file mode 100644 index 00000000..b912ab4a --- /dev/null +++ b/appliances/lib/tests.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'rspec' +require 'tmpdir' +require_relative 'helpers.rb' + +RSpec.describe 'load_env' do + it 'should load env vars from file' do + tests = [ + [ { :E1 => 'V1', + :E2 => 'V2', + :E3 => 'V3' }, + <<~INPUT + export E1="V1" + export E2="V2" + export E3="V3" + INPUT + ], + [ { :E1 => '"', + :E2 => "\n", + :E3 => "\\n" }, + <<~'INPUT' + export E1="\"" + export E2="\n" + export E3="\\n" + INPUT + ], + ] + Dir.mktmpdir do |dir| + tests.each do |output, input| + File.write "#{dir}/one_env", input + load_env "#{dir}/one_env" + output.each do |k, v| + expect(ENV[k.to_s]).to eq v + end + end + end + end +end + +RSpec.describe 'bash' do + it 'should raise' do + allow(self).to receive(:exit).and_return nil + expect { bash 'false' }.to raise_error(RuntimeError) + end + it 'should not raise' do + allow(self).to receive(:exit).and_return nil + expect { bash 'false', terminate: true }.not_to raise_error + end +end + +RSpec.describe 'ipv4?' do + it 'should evaluate to true' do + ipv4s = %w[ + 10.11.12.13 + 10.11.12.13/24 + 10.11.12.13/32 + 192.168.144.120 + ] + ipv4s.each do |item| + expect(ipv4?(item)).to be true + end + end + it 'should evaluate to false' do + ipv4s = %w[ + 10.11.12 + 10.11.12. + 10.11.12.256 + asd.168.144.120 + 192.168.144.96-192.168.144.120 + ] + ipv4s.each do |item| + expect(ipv4?(item)).to be false + end + end +end + +RSpec.describe 'hashmap' do + tests = [ + [ [{}, {}], {} ], + + [ [{a: 1}, {b: 2}], {a: 1, b: 2} ], + + [ [{a: 1, b: 3}, {b: 2}], {a: 1, b: 2} ], + + [ [{a: 1, b: 2}, {b: []}], {a: 1, b: []} ], + + [ [{a: 1, b: [:c]}, {b: []}], {a: 1, b: []} ], + + [ [{a: 1, b: {c: 3, d: 3}}, {b: {c: 2, e: 4}}], {a: 1, b: {c: 2, d: 3, e: 4}} ] + ] + it 'should recursively combine two hashmaps' do + tests.each do |(a, b), c| + expect(hashmap.combine(a, b)).to eq c + end + end + it 'should recursively combine two hashmaps (in-place)' do + tests.each do |(a, b), c| + hashmap.combine!(a, b) + expect(a).to eq c + end + end +end + +RSpec.describe 'sorted_deps' do + it 'should sort dependencies' do + tests = [ + [ { :a => [:b], + :b => [:c], + :c => [:d], + :d => [] }, [:d, :c, :b, :a] ], + + [ { :d => [:b], + :c => [:b, :d], + :b => [:a], + :a => [] }, [:a, :b, :d, :c] ], + + [ + { + :Failover => [:Keepalived], + :NAT4 => [:Failover, :Router4], + :Keepalived => [], + :Router4 => [:Failover] + }, + [ + :Keepalived, + :Failover, + :Router4, + :NAT4 + ] + ] + ] + tests.each do |input, output| + expect(sorted_deps(input)).to eq output + end + end +end + +RSpec.describe 'set_motd' do + it 'should render motd' do + output = <<~'OUTPUT' + . + ___ _ __ ___ + / _ \ | '_ \ / _ \ OpenNebula Service Appliance + | (_) || | | || __/ + \___/ |_| |_| \___| + + + All set and ready to serve 8) + + OUTPUT + Dir.mktmpdir do |dir| + set_motd :bootstrap, :success, "#{dir}/motd" + result = File.read "#{dir}/motd" + expect(result).to eq output.delete_prefix('.') + end + end +end + +RSpec.describe 'set_status' do + it 'should set status' do + allow(self).to receive(:set_motd).and_return nil + tests = [ + [ :install, 'install_started' ], + [ :success, 'install_success' ], + [ :configure, 'configure_started' ], + [ :success, 'configure_success' ], + [ :bootstrap, 'bootstrap_started' ], + [ :failure, 'bootstrap_failure' ] + ] + Dir.mktmpdir do |dir| + tests.each do |input, output| + set_status input, "#{dir}/status" + result = File.open("#{dir}/status", &:gets).strip + expect(result).to eq output + end + end + end +end diff --git a/appliances/lib/tests.sh b/appliances/lib/tests.sh new file mode 100755 index 00000000..819172f9 --- /dev/null +++ b/appliances/lib/tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -eu -o pipefail; shopt -qs failglob + +find . -type f -name 'tests.rb' | while read FILE; do + (cd $(dirname "$FILE")/ && echo ">> $FILE <<" && rspec $(basename "$FILE")) +done diff --git a/appliances/scripts/net-90 b/appliances/scripts/net-90 new file mode 100755 index 00000000..f09075ed --- /dev/null +++ b/appliances/scripts/net-90 @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Runs OpenNebula service appliances configuration & bootstrap script + +set -o errexit + +[[ -x /etc/one-appliance/service ]] && /etc/one-appliance/service configure bootstrap diff --git a/appliances/scripts/net-99 b/appliances/scripts/net-99 new file mode 100755 index 00000000..b60ad38f --- /dev/null +++ b/appliances/scripts/net-99 @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +: "${ENV_FILE:=/var/run/one-context/one_env}" + +set -o errexit + +if [[ "$REPORT_READY" != "YES" ]]; then + exit +fi + +# $TOKENTXT is available only through the env. file +if [[ -f "${ENV_FILE}" ]]; then + . "${ENV_FILE}" +fi + +# Reports only if ONE service appliance bootstrapped successfully +if [[ -x '/etc/one-appliance/service' ]]; then + _STATUS=$(cat '/etc/one-appliance/status' 2>/dev/null) + if [[ "$_STATUS" != 'bootstrap_success' ]]; then + exit + fi +fi + +### + +if which onegate >/dev/null 2>&1; then + if onegate vm update --data READY=YES; then + exit + fi +fi + +if which curl >/dev/null 2>&1; then + if curl -X PUT "$ONEGATE_ENDPOINT/vm" \ + --header "X-ONEGATE-TOKEN: $TOKENTXT" \ + --header "X-ONEGATE-VMID: $VMID" \ + -d READY=YES; then + exit + fi +fi + +if which wget >/dev/null 2>&1; then + if wget --method PUT "$ONEGATE_ENDPOINT/vm" \ + --header "X-ONEGATE-TOKEN: $TOKENTXT" \ + --header "X-ONEGATE-VMID: $VMID" \ + --body-data READY=YES; then + exit + fi +fi diff --git a/appliances/service b/appliances/service new file mode 100755 index 00000000..f745c751 --- /dev/null +++ b/appliances/service @@ -0,0 +1,84 @@ +#!/usr/bin/env ruby + +# frozen_string_literal: true + +begin + require '/etc/one-appliance/lib/helpers.rb' +rescue LoadError + require_relative './lib/helpers.rb' +end + +require 'optparse' + +STEPS = %w[install configure bootstrap] + +SERVICE_D = File.join File.dirname(__FILE__), 'service.d' + +SERVICE_LOGDIR = '/var/log/one-appliance' + +def include_services(service_d = SERVICE_D) + Dir[File.join service_d, '**/main.rb'].each do |path| + require path + end + + before = Module.constants + + include Service + + after = Module.constants + + (after - before).sort.map do |constant| + Module.const_get constant + end +end + +if caller.empty? + steps = <<~STEPS + Steps: + #{STEPS.join(' ')} + STEPS + + parser = OptionParser.new do |opts| + opts.banner = "Usage: #{$PROGRAM_NAME} [options] step1 step2 ..." + opts.separator 'Options:' + opts.on_tail('-h', '--help', 'Show help message') do + puts opts + puts steps + exit 0 + end + end + parser.parse! + + if ARGV.empty? || !(ARGV - STEPS).empty? + puts parser.help + puts steps + exit 1 + end + + Dir.mkdir(SERVICE_LOGDIR, 0750) unless File.exist?(SERVICE_LOGDIR) + + stdout, stderr = $stdout.dup, $stderr.dup + + services = sorted_deps(include_services.to_h { |s| [s, s.const_get(:DEPENDS_ON)] }) + + ARGV.product(services).each do |step, service| + set_status step + + open File.join(SERVICE_LOGDIR, "#{step}.log"), 'a' do |logfile| + $stdout.reopen logfile + $stderr.reopen logfile + service.method(step).call + rescue StandardError => e + stderr.puts e.full_message + stderr.flush + raise e + ensure + $stdout.flush + $stderr.flush + end + end + + $stdout, $stderr = stdout, stderr + + set_status :success +end diff --git a/packer/service_VRouter/10-update.sh b/packer/service_VRouter/10-update.sh new file mode 100644 index 00000000..f7f92812 --- /dev/null +++ b/packer/service_VRouter/10-update.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +# (Auto)Removes unneeded packages and upgrades +# the distro. + +exec 1>&2 +set -ex + +service haveged stop ||: + +apk update + +apk --no-cache add \ + bash curl ethtool gawk grep iproute2 jq ruby sed tcpdump + +sync diff --git a/packer/service_VRouter/81-configure-ssh.sh b/packer/service_VRouter/81-configure-ssh.sh new file mode 100644 index 00000000..7014053f --- /dev/null +++ b/packer/service_VRouter/81-configure-ssh.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Configures critical settings for OpenSSH server. + +exec 1>&2 +set -o errexit -o nounset -o pipefail +set -x + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PasswordAuthentication no" } +/^[#\s]*PasswordAuthentication\s*/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PermitRootLogin without-password" } +/^[#\s]*PermitRootLogin\s*/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "UseDNS no" } +/^[#\s]*UseDNS\s*/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "AllowTcpForwarding yes" } +/^[#\s]*AllowTcpForwarding\s*/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "AllowAgentForwarding yes" } +/^[#\s]*AllowAgentForwarding\s*/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +sync diff --git a/packer/service_VRouter/82-configure-context.sh b/packer/service_VRouter/82-configure-context.sh new file mode 100644 index 00000000..8552eb54 --- /dev/null +++ b/packer/service_VRouter/82-configure-context.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Configures and enables service context. + +exec 1>&2 +set -o errexit -o nounset -o pipefail +set -x + +printf '#!/bin/sh\n\ntrue\n' > /etc/one-context.d/loc-12-firewall +printf '#!/bin/sh\n\ntrue\n' > /etc/one-context.d/loc-15-ip_forward +printf '#!/bin/sh\n\ntrue\n' > /etc/one-context.d/loc-15-keepalived + +mv /etc/one-appliance/net-90 /etc/one-context.d/net-90-service-appliance +mv /etc/one-appliance/net-99 /etc/one-context.d/net-99-report-ready + +chown root:root /etc/one-context.d/* +chmod u=rwx,go=rx /etc/one-context.d/* + +sync diff --git a/packer/service_VRouter/VRouter.pkr.hcl b/packer/service_VRouter/VRouter.pkr.hcl new file mode 100644 index 00000000..3d8aa034 --- /dev/null +++ b/packer/service_VRouter/VRouter.pkr.hcl @@ -0,0 +1,103 @@ +source "null" "null" { communicator = "none" } + +build { + sources = ["source.null.null"] + + provisioner "shell-local" { + inline = [ + "mkdir -p ${var.input_dir}/context", + "${var.input_dir}/gen_context > ${var.input_dir}/context/context.sh", + "mkisofs -o ${var.input_dir}/${var.appliance_name}-context.iso -V CONTEXT -J -R ${var.input_dir}/context", + ] + } +} + +# Build VM image +source "qemu" "VRouter" { + cpus = 2 + memory = 2048 + accelerator = "kvm" + + iso_url = "export/alpine318.qcow2" + iso_checksum = "none" + + headless = var.headless + + disk_image = true + disk_cache = "unsafe" + disk_interface = "virtio" + net_device = "virtio-net" + format = "qcow2" + disk_compression = false + disk_size = 2048 + + output_directory = var.output_dir + + qemuargs = [ ["-serial", "stdio"], + ["-cpu", "host"], + ["-cdrom", "${var.input_dir}/${var.appliance_name}-context.iso"], + # MAC addr needs to mach ETH0_MAC from context iso + ["-netdev", "user,id=net0,hostfwd=tcp::{{ .SSHHostPort }}-:22"], + ["-device", "virtio-net-pci,netdev=net0,mac=00:11:22:33:44:55"] + ] + ssh_username = "root" + ssh_password = "opennebula" + ssh_wait_timeout = "900s" + shutdown_command = "poweroff" + vm_name = "${var.appliance_name}" +} + +build { + sources = ["source.qemu.VRouter"] + + # update & revert insecure ssh options done by context start_script + provisioner "shell" { + scripts = [ + "${var.input_dir}/10-update.sh", + "${var.input_dir}/81-configure-ssh.sh" + ] + } + + provisioner "shell" { + inline_shebang = "/bin/bash -e" + inline = [ + "install -o 0 -g 0 -m u=rwx,g=rx,o= -d /etc/one-appliance/{,service.d/,lib/}", + "install -o 0 -g 0 -m u=rwx,g=rx,o=rx -d /opt/one-appliance/{,bin/}" + ] + } + + provisioner "file" { + sources = [ + "appliances/service", + "appliances/scripts/net-90", + "appliances/scripts/net-99", + ] + destination = "/etc/one-appliance/" + } + provisioner "file" { + sources = ["appliances/lib/helpers.rb"] + destination = "/etc/one-appliance/lib/" + } + provisioner "file" { + sources = ["appliances/VRouter"] + destination = "/etc/one-appliance/service.d/" + } + + provisioner "shell" { + scripts = ["${var.input_dir}/82-configure-context.sh"] + } + + provisioner "shell" { + inline_shebang = "/bin/bash -e" + inline = ["/etc/one-appliance/service install"] + } + + post-processor "shell-local" { + execute_command = ["bash", "-c", "{{.Vars}} {{.Script}}"] + environment_vars = [ + "OUTPUT_DIR=${var.output_dir}", + "APPLIANCE_NAME=${var.appliance_name}", + ] + scripts = [ "packer/postprocess.sh" ] + } +} diff --git a/packer/service_VRouter/gen_context b/packer/service_VRouter/gen_context new file mode 100755 index 00000000..18f6985e --- /dev/null +++ b/packer/service_VRouter/gen_context @@ -0,0 +1,30 @@ +#!/bin/bash +SCRIPT=$(cat <> FILENAME } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PermitRootLogin yes" } +/^[#\s]*PermitRootLogin\s*/ { \$0 = update; found = 1 } +{ print } +END { if (!found) print update >> FILENAME } +EOF + +rc-service sshd reload + +echo "nameserver 1.1.1.1" > /etc/resolv.conf +MAINEND +) + +cat<