empeira 0.1.0.pre.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +661 -0
- data/README.md +150 -0
- data/bin/empeira +18 -0
- data/config/defaults.yaml +229 -0
- data/docs/addons.md +331 -0
- data/docs/architecture.md +277 -0
- data/docs/configuration.md +1204 -0
- data/docs/development.md +472 -0
- data/docs/hiera-and-eyaml.md +361 -0
- data/docs/installation.md +280 -0
- data/docs/networking.md +362 -0
- data/docs/nodes.md +642 -0
- data/docs/proxy.md +136 -0
- data/docs/troubleshooting.md +361 -0
- data/lib/empeira/addons/environment.rb +82 -0
- data/lib/empeira/addons/error.rb +14 -0
- data/lib/empeira/addons/installation.rb +119 -0
- data/lib/empeira/addons/manifest.rb +112 -0
- data/lib/empeira/addons/service.rb +50 -0
- data/lib/empeira/addons/storage.rb +94 -0
- data/lib/empeira/agent/acquisition.rb +120 -0
- data/lib/empeira/agent/artifact.rb +41 -0
- data/lib/empeira/agent/authentication.rb +122 -0
- data/lib/empeira/agent/cache.rb +122 -0
- data/lib/empeira/agent/cache_storage.rb +82 -0
- data/lib/empeira/agent/download.rb +84 -0
- data/lib/empeira/agent/native_commands.rb +84 -0
- data/lib/empeira/agent/release_sources.rb +64 -0
- data/lib/empeira/agent/target.rb +66 -0
- data/lib/empeira/agent/versions.rb +29 -0
- data/lib/empeira/application.rb +86 -0
- data/lib/empeira/build.json +5 -0
- data/lib/empeira/build_info.rb +46 -0
- data/lib/empeira/cli/addons.rb +45 -0
- data/lib/empeira/cli/base.rb +73 -0
- data/lib/empeira/cli/completion.rb +42 -0
- data/lib/empeira/cli/config.rb +22 -0
- data/lib/empeira/cli/main.rb +114 -0
- data/lib/empeira/cli/node.rb +87 -0
- data/lib/empeira/cli/progress.rb +169 -0
- data/lib/empeira/cli/proxy.rb +36 -0
- data/lib/empeira/cli/update.rb +23 -0
- data/lib/empeira/completion/addons.rb +93 -0
- data/lib/empeira/completion/bash.rb +62 -0
- data/lib/empeira/completion/catalog.rb +170 -0
- data/lib/empeira/configuration/additional_services.rb +74 -0
- data/lib/empeira/configuration/addons.rb +85 -0
- data/lib/empeira/configuration/agent_schema.rb +163 -0
- data/lib/empeira/configuration/agent_values.rb +54 -0
- data/lib/empeira/configuration/bootstrap_guests.rb +42 -0
- data/lib/empeira/configuration/command_mocks.rb +65 -0
- data/lib/empeira/configuration/display.rb +20 -0
- data/lib/empeira/configuration/dns_rewrites.rb +58 -0
- data/lib/empeira/configuration/document.rb +67 -0
- data/lib/empeira/configuration/eyaml.rb +46 -0
- data/lib/empeira/configuration/hiera_modulepath.rb +40 -0
- data/lib/empeira/configuration/hiera_mount_schema.rb +62 -0
- data/lib/empeira/configuration/hiera_mounts.rb +83 -0
- data/lib/empeira/configuration/image_schema.rb +68 -0
- data/lib/empeira/configuration/loader.rb +70 -0
- data/lib/empeira/configuration/merge.rb +48 -0
- data/lib/empeira/configuration/network_redirects.rb +73 -0
- data/lib/empeira/configuration/node_images.rb +64 -0
- data/lib/empeira/configuration/schema.rb +215 -0
- data/lib/empeira/configuration/server_mounts.rb +106 -0
- data/lib/empeira/configuration/service_configuration.rb +40 -0
- data/lib/empeira/configuration/ssh_preferences.rb +83 -0
- data/lib/empeira/configuration/user.rb +46 -0
- data/lib/empeira/configuration/vm_interfaces.rb +147 -0
- data/lib/empeira/control_plane/additional_configurations.rb +106 -0
- data/lib/empeira/control_plane/browser.rb +73 -0
- data/lib/empeira/control_plane/controller.rb +763 -0
- data/lib/empeira/control_plane/discovery.rb +69 -0
- data/lib/empeira/control_plane/environment.rb +101 -0
- data/lib/empeira/control_plane/files.rb +263 -0
- data/lib/empeira/control_plane/health.rb +104 -0
- data/lib/empeira/control_plane/plan.rb +356 -0
- data/lib/empeira/control_plane/storage.rb +48 -0
- data/lib/empeira/errors.rb +9 -0
- data/lib/empeira/execution/console.rb +45 -0
- data/lib/empeira/execution/diagnostics.rb +210 -0
- data/lib/empeira/execution/launch_error.rb +14 -0
- data/lib/empeira/execution/result.rb +11 -0
- data/lib/empeira/execution/runner.rb +235 -0
- data/lib/empeira/execution/terminal.rb +35 -0
- data/lib/empeira/hostname_pattern.rb +14 -0
- data/lib/empeira/images/configuration.rb +44 -0
- data/lib/empeira/images/identity.rb +30 -0
- data/lib/empeira/images/recipe.rb +22 -0
- data/lib/empeira/images/reference.rb +43 -0
- data/lib/empeira/images/source.rb +19 -0
- data/lib/empeira/immutable.rb +19 -0
- data/lib/empeira/infrastructure/cleanup.rb +328 -0
- data/lib/empeira/infrastructure/definition.rb +59 -0
- data/lib/empeira/infrastructure/owned_files.rb +63 -0
- data/lib/empeira/infrastructure/recovery_inventory.rb +53 -0
- data/lib/empeira/infrastructure/service.rb +237 -0
- data/lib/empeira/infrastructure/status.rb +73 -0
- data/lib/empeira/infrastructure/store.rb +274 -0
- data/lib/empeira/modules/events.rb +67 -0
- data/lib/empeira/modules/git_safety.rb +60 -0
- data/lib/empeira/modules/host_git.rb +91 -0
- data/lib/empeira/modules/image.rb +27 -0
- data/lib/empeira/modules/installer.rb +67 -0
- data/lib/empeira/modules/request.rb +68 -0
- data/lib/empeira/modules/state.rb +87 -0
- data/lib/empeira/modules/storage.rb +35 -0
- data/lib/empeira/network/bootstrap_proxy.rb +157 -0
- data/lib/empeira/network/definition.rb +61 -0
- data/lib/empeira/network/direct_egress.rb +133 -0
- data/lib/empeira/network/egress.rb +27 -0
- data/lib/empeira/network/gateway.rb +79 -0
- data/lib/empeira/network/host_policy.rb +53 -0
- data/lib/empeira/network/naming.rb +28 -0
- data/lib/empeira/network/peer/adapter_image.rb +32 -0
- data/lib/empeira/network/peer/allocation.rb +45 -0
- data/lib/empeira/network/peer/attachment.rb +27 -0
- data/lib/empeira/network/peer/backend.rb +108 -0
- data/lib/empeira/network/peer/channel.rb +133 -0
- data/lib/empeira/network/peer/channel_server.rb +71 -0
- data/lib/empeira/network/peer/docker_adapter.rb +165 -0
- data/lib/empeira/network/peer/helper_security.rb +40 -0
- data/lib/empeira/network/peer/layout.rb +55 -0
- data/lib/empeira/network/peer/linux_podman.rb +114 -0
- data/lib/empeira/network/peer/podman_machine.rb +112 -0
- data/lib/empeira/network/policy.rb +39 -0
- data/lib/empeira/network/proxy_bindings.rb +52 -0
- data/lib/empeira/network/proxy_policy.rb +64 -0
- data/lib/empeira/network/redirects.rb +89 -0
- data/lib/empeira/network/resource.rb +50 -0
- data/lib/empeira/node/agent_installer.rb +31 -0
- data/lib/empeira/node/agent_package.rb +125 -0
- data/lib/empeira/node/agent_repository.rb +160 -0
- data/lib/empeira/node/apt_configuration.rb +85 -0
- data/lib/empeira/node/bootstrap.rb +17 -0
- data/lib/empeira/node/certificates.rb +98 -0
- data/lib/empeira/node/command_mock.rb +38 -0
- data/lib/empeira/node/command_mocks.rb +114 -0
- data/lib/empeira/node/container.rb +134 -0
- data/lib/empeira/node/container_agent_bootstrap.rb +43 -0
- data/lib/empeira/node/container_bootstrap.rb +52 -0
- data/lib/empeira/node/container_guest.rb +42 -0
- data/lib/empeira/node/container_lifecycle.rb +145 -0
- data/lib/empeira/node/container_preparation.rb +144 -0
- data/lib/empeira/node/container_state.rb +129 -0
- data/lib/empeira/node/definition.rb +20 -0
- data/lib/empeira/node/diagnostic_command.rb +19 -0
- data/lib/empeira/node/dnf_agent_repository.rb +202 -0
- data/lib/empeira/node/external_fact.rb +21 -0
- data/lib/empeira/node/image.rb +38 -0
- data/lib/empeira/node/interactive_tools.rb +33 -0
- data/lib/empeira/node/interface.rb +18 -0
- data/lib/empeira/node/inventory.rb +143 -0
- data/lib/empeira/node/package_bootstrap.rb +129 -0
- data/lib/empeira/node/package_proxy.rb +102 -0
- data/lib/empeira/node/package_sources.rb +19 -0
- data/lib/empeira/node/puppet_command.rb +12 -0
- data/lib/empeira/node/rpm_configuration.rb +62 -0
- data/lib/empeira/node/run_request.rb +52 -0
- data/lib/empeira/node/runtime_proxy.rb +86 -0
- data/lib/empeira/node/service.rb +72 -0
- data/lib/empeira/node/ssh_client.rb +25 -0
- data/lib/empeira/node/ssh_credentials.rb +78 -0
- data/lib/empeira/node/ssh_endpoint.rb +58 -0
- data/lib/empeira/node/ssh_identity.rb +37 -0
- data/lib/empeira/node/user_ssh.rb +60 -0
- data/lib/empeira/node/vm.rb +89 -0
- data/lib/empeira/node/vm_bootstrap.rb +53 -0
- data/lib/empeira/node/vm_lifecycle.rb +212 -0
- data/lib/empeira/node/vm_preflight.rb +37 -0
- data/lib/empeira/node/vm_preparation.rb +161 -0
- data/lib/empeira/node/vm_state.rb +161 -0
- data/lib/empeira/platform/facts.rb +66 -0
- data/lib/empeira/platform/kvm.rb +91 -0
- data/lib/empeira/platform/locations.rb +51 -0
- data/lib/empeira/platform/resolvers.rb +85 -0
- data/lib/empeira/platform/routes.rb +50 -0
- data/lib/empeira/progress.rb +70 -0
- data/lib/empeira/project_root.rb +47 -0
- data/lib/empeira/providers/lifecycle.rb +96 -0
- data/lib/empeira/providers/registry.rb +21 -0
- data/lib/empeira/providers/resource.rb +32 -0
- data/lib/empeira/providers.rb +55 -0
- data/lib/empeira/runtime/agent_helper.rb +34 -0
- data/lib/empeira/runtime/container.rb +184 -0
- data/lib/empeira/runtime/diagnostic_execution.rb +52 -0
- data/lib/empeira/runtime/docker.rb +87 -0
- data/lib/empeira/runtime/image_metadata.rb +83 -0
- data/lib/empeira/runtime/interface.rb +8 -0
- data/lib/empeira/runtime/mount_probe.rb +57 -0
- data/lib/empeira/runtime/podman.rb +122 -0
- data/lib/empeira/runtime/recipe_build.rb +103 -0
- data/lib/empeira/runtime/registry_diagnostics.rb +23 -0
- data/lib/empeira/runtime/service_arguments.rb +55 -0
- data/lib/empeira/runtime/service_images.rb +118 -0
- data/lib/empeira/runtime/service_inspection.rb +159 -0
- data/lib/empeira/runtime/services.rb +120 -0
- data/lib/empeira/runtime/update_helper.rb +97 -0
- data/lib/empeira/runtime/workspace_network.rb +69 -0
- data/lib/empeira/server/environment_configuration.rb +53 -0
- data/lib/empeira/server/http.rb +38 -0
- data/lib/empeira/server/puppetdb_runtime.rb +74 -0
- data/lib/empeira/server/relay_certificate.rb +76 -0
- data/lib/empeira/server/runtime.rb +90 -0
- data/lib/empeira/services/definition.rb +42 -0
- data/lib/empeira/updates/images.rb +104 -0
- data/lib/empeira/updates/modules.rb +45 -0
- data/lib/empeira/updates/service.rb +45 -0
- data/lib/empeira/version_requirement.rb +23 -0
- data/lib/empeira/vm/agent.rb +66 -0
- data/lib/empeira/vm/bootstrap_requirements.rb +69 -0
- data/lib/empeira/vm/certificate_runtime.rb +30 -0
- data/lib/empeira/vm/cloud_init.rb +171 -0
- data/lib/empeira/vm/disk.rb +136 -0
- data/lib/empeira/vm/guest.rb +123 -0
- data/lib/empeira/vm/guest_channel.rb +110 -0
- data/lib/empeira/vm/guest_network.rb +94 -0
- data/lib/empeira/vm/guest_output.rb +52 -0
- data/lib/empeira/vm/guest_shell.rb +79 -0
- data/lib/empeira/vm/guest_upload.rb +43 -0
- data/lib/empeira/vm/image_cache.rb +77 -0
- data/lib/empeira/vm/image_source.rb +91 -0
- data/lib/empeira/vm/interface.rb +8 -0
- data/lib/empeira/vm/interface_ownership.rb +124 -0
- data/lib/empeira/vm/interface_routes.rb +101 -0
- data/lib/empeira/vm/interfaces.rb +240 -0
- data/lib/empeira/vm/management.rb +73 -0
- data/lib/empeira/vm/prerequisites.rb +42 -0
- data/lib/empeira/vm/qemu.rb +83 -0
- data/lib/empeira/vm/qemu_runtime.rb +404 -0
- data/lib/empeira/vm/removal.rb +30 -0
- data/lib/empeira/vm/root_disk.rb +92 -0
- data/lib/empeira/vm/session_lock.rb +56 -0
- data/lib/empeira/vm/system_ssh.rb +29 -0
- data/lib/empeira/workspace.rb +42 -0
- data/lib/empeira.rb +209 -0
- data/resources/browser/relay.rb +22 -0
- data/resources/gateway/Containerfile +8 -0
- data/resources/gateway/bridge.rb +66 -0
- data/resources/gateway/configure.rb +323 -0
- data/resources/modules/Containerfile +15 -0
- data/resources/modules/Gemfile +5 -0
- data/resources/modules/Gemfile.lock +105 -0
- data/resources/modules/worker.rb +161 -0
- data/resources/network/adapter/Containerfile +13 -0
- data/resources/network/adapter/bridge_linux.go +51 -0
- data/resources/network/adapter/framing.go +45 -0
- data/resources/network/adapter/framing_test.go +53 -0
- data/resources/network/adapter/go.mod +3 -0
- data/resources/network/adapter/main_linux.go +273 -0
- data/resources/network/channel.rb +18 -0
- data/resources/network/ssh_connect.rb +32 -0
- data/resources/nodes/apt_sources.rb +66 -0
- data/resources/nodes/debian/Containerfile +10 -0
- data/resources/nodes/diagnostic_exec.rb +109 -0
- data/resources/nodes/interactive_tools.rb +139 -0
- data/resources/nodes/managed_file.rb +91 -0
- data/resources/nodes/management/guest_agent.py +415 -0
- data/resources/nodes/management/setup.sh +20 -0
- data/resources/nodes/management/shell_rc.sh +12 -0
- data/resources/nodes/management/system_account.sh +24 -0
- data/resources/nodes/management/system_home.sh +15 -0
- data/resources/nodes/rpm/Containerfile +10 -0
- data/resources/nodes/runtime_proxy.rb +244 -0
- data/resources/nodes/ssh/proxy.rb +16 -0
- data/resources/nodes/ssh/sshd_config +15 -0
- data/resources/nodes/ssh/start +6 -0
- data/resources/proxy/Containerfile +10 -0
- data/resources/relay/Containerfile +5 -0
- data/resources/relay/puppetdb.rb +81 -0
- data/resources/relay/server_environment.rb +10 -0
- data/resources/relay/server_start.sh +41 -0
- metadata +326 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Empeira
|
|
7
|
+
module VM
|
|
8
|
+
# Private NoCloud material is removed after initial guest configuration.
|
|
9
|
+
# rubocop:disable-next Metrics/ClassLength -- Seed, key and bootstrap material are one guest boundary.
|
|
10
|
+
class CloudInit
|
|
11
|
+
USER = 'empeira'
|
|
12
|
+
|
|
13
|
+
def initialize(context:, runner:, executable_path: ENV.fetch('PATH', ''))
|
|
14
|
+
@context = context
|
|
15
|
+
@runner = runner
|
|
16
|
+
@executable_path = executable_path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def prepare(record)
|
|
20
|
+
Management.validate!(record)
|
|
21
|
+
directory = node_directory(record.fetch('hostname'))
|
|
22
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
23
|
+
credentials = Node::SSHCredentials.new(context: @context, runner: @runner, provider: 'vm',
|
|
24
|
+
hostname: record.fetch('hostname'))
|
|
25
|
+
credentials.prepare
|
|
26
|
+
key = credentials.key_path
|
|
27
|
+
files = seed_files(directory, record, File.read("#{key}.pub").strip)
|
|
28
|
+
create_iso(directory, files)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def finish(record, guest:)
|
|
32
|
+
directory = node_directory(record.fetch('hostname'))
|
|
33
|
+
files = seed_files(directory, record, File.read("#{key_path(record.fetch('hostname'))}.pub").strip,
|
|
34
|
+
console: false)
|
|
35
|
+
create_iso(directory, files)
|
|
36
|
+
paths = %w[user-data.txt user-data.txt.i cloud-config.txt obj.pkl].map do |name|
|
|
37
|
+
"/var/lib/cloud/instance/#{name}"
|
|
38
|
+
end
|
|
39
|
+
result = guest.run(record, ['rm', '-f', *paths])
|
|
40
|
+
return if result.success?
|
|
41
|
+
|
|
42
|
+
details = Execution::Diagnostics.command(result, operation: 'Guest cloud-init credential cleanup', tool: 'rm')
|
|
43
|
+
raise Error, "Cannot remove temporary guest cloud-init credentials\n#{details}", cause: nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def key_path(hostname)
|
|
47
|
+
node_directory(hostname).join('id_ed25519')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate!
|
|
51
|
+
bootstrap_files
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def create_iso(directory, files)
|
|
57
|
+
output = directory.join('seed.iso')
|
|
58
|
+
tool = locate('xorriso') || raise(UnavailableFeature, 'xorriso is required for VM cloud-init seeds')
|
|
59
|
+
Tempfile.create(['seed-', '.iso'], directory) do |temporary|
|
|
60
|
+
result = @runner.run(tool, arguments: ['-as', 'mkisofs', '-quiet', '-volid', 'cidata',
|
|
61
|
+
'-output', temporary.path, *files.map(&:to_s)], timeout: 30)
|
|
62
|
+
raise Error, 'Cannot create VM cloud-init seed' unless result.success?
|
|
63
|
+
|
|
64
|
+
File.chmod(0o600, temporary.path)
|
|
65
|
+
File.rename(temporary.path, output)
|
|
66
|
+
end
|
|
67
|
+
output
|
|
68
|
+
ensure
|
|
69
|
+
files&.each { |path| FileUtils.rm_f(path) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def seed_files(directory, record, public_key, console: true)
|
|
73
|
+
data = cloud_config(record, public_key, console: console)
|
|
74
|
+
metadata = { 'instance-id' => "#{@context.workspace.id}-#{record.fetch('hostname')}",
|
|
75
|
+
'local-hostname' => record.fetch('hostname') }
|
|
76
|
+
network = network_config(record)
|
|
77
|
+
{ 'user-data' => "#cloud-config\n#{YAML.dump(data)}", 'meta-data' => YAML.dump(metadata),
|
|
78
|
+
'network-config' => YAML.dump(network) }.map do |name, content|
|
|
79
|
+
path = directory.join(name)
|
|
80
|
+
File.write(path, content, mode: 'w', perm: 0o600)
|
|
81
|
+
File.chmod(0o600, path)
|
|
82
|
+
path
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def network_config(record)
|
|
87
|
+
{ 'version' => 2, 'ethernets' => {
|
|
88
|
+
'peer' => { 'match' => { 'macaddress' => record.fetch('mac_address') }, 'set-name' => 'eth0',
|
|
89
|
+
'addresses' => ["#{record.fetch('peer').fetch('ip')}/24"], 'dhcp4' => false, 'dhcp6' => false,
|
|
90
|
+
'routes' => [{ 'to' => '0.0.0.0/0', 'via' => record.fetch('peer').fetch('gateway') }],
|
|
91
|
+
'link-local' => [], 'nameservers' => { 'addresses' => [record.fetch('peer').fetch('dns')] } }
|
|
92
|
+
} }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cloud_config(record, public_key, console: true)
|
|
96
|
+
scripts = bootstrap_files
|
|
97
|
+
{ 'hostname' => record.fetch('hostname'), 'prefer_fqdn_over_hostname' => true,
|
|
98
|
+
'fqdn' => record.fetch('hostname'), 'manage_etc_hosts' => true,
|
|
99
|
+
'users' => [system_user],
|
|
100
|
+
'ssh_pwauth' => false, 'disable_root' => true,
|
|
101
|
+
'write_files' => cloud_files(scripts) + management_files(record, public_key),
|
|
102
|
+
'runcmd' => cloud_commands(scripts),
|
|
103
|
+
**(console ? console_password : {}) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def console_password
|
|
107
|
+
password = @context.configuration.dig('vm', 'console', 'root_password')
|
|
108
|
+
return {} unless password
|
|
109
|
+
|
|
110
|
+
{ 'chpasswd' => { 'expire' => false,
|
|
111
|
+
'users' => [{ 'name' => 'root', 'password' => password, 'type' => 'text' }] } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cloud_files(scripts)
|
|
115
|
+
Node::Bootstrap.new(provider: 'vm').files.map do |file|
|
|
116
|
+
{ 'path' => file.path, 'permissions' => format('%04o', file.mode), 'content' => file.content }
|
|
117
|
+
end + [{ 'path' => '/etc/cloud/cloud.cfg.d/99-empeira-hostname.cfg', 'permissions' => '0644',
|
|
118
|
+
'content' => "preserve_hostname: true\n" },
|
|
119
|
+
{ 'path' => '/etc/sysctl.d/90-empeira-ipv4.conf', 'permissions' => '0644',
|
|
120
|
+
'content' => "net.ipv6.conf.all.disable_ipv6=1\nnet.ipv6.conf.default.disable_ipv6=1\n" }] + scripts
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def cloud_commands(scripts)
|
|
124
|
+
management = [[Management::SETUP]]
|
|
125
|
+
[['sysctl', '--system'], *management, *scripts.map { |entry| [entry.fetch('path')] }]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def system_user
|
|
129
|
+
{ 'name' => USER, 'system' => true, 'homedir' => '/var/lib/empeira',
|
|
130
|
+
'lock_passwd' => true, 'shell' => '/bin/bash' }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def management_files(record, public_key)
|
|
134
|
+
Management.files(record, system_key: public_key)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def bootstrap_files
|
|
138
|
+
config = @context.configuration.fetch('bootstrap')
|
|
139
|
+
return [] unless config.fetch('enabled')
|
|
140
|
+
|
|
141
|
+
config.fetch('scripts').each_with_index.map { |relative, index| bootstrap_entry(relative, index) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def bootstrap_entry(relative, index)
|
|
145
|
+
source = @context.project.path.join(relative).realpath
|
|
146
|
+
unless source.file? && source.to_s.start_with?("#{@context.project.path}/") && source.size <= 1_000_000
|
|
147
|
+
raise ConfigurationError, "bootstrap.scripts.#{index} must be a project-local file below 1 MB"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
content = File.read(source, encoding: 'UTF-8')
|
|
151
|
+
raise ConfigurationError, "bootstrap.scripts.#{index} must be UTF-8" unless content.valid_encoding?
|
|
152
|
+
|
|
153
|
+
{ 'path' => "/usr/local/libexec/empeira-bootstrap-#{index}", 'permissions' => '0700',
|
|
154
|
+
'content' => content }
|
|
155
|
+
rescue SystemCallError
|
|
156
|
+
raise ConfigurationError, "bootstrap.scripts.#{index} must resolve to a project-local file", cause: nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def node_directory(hostname)
|
|
160
|
+
@context.locations.workspace(@context.workspace).join('vms', hostname)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def locate(name)
|
|
164
|
+
@executable_path.split(File::PATH_SEPARATOR).filter_map do |directory|
|
|
165
|
+
path = Pathname(directory).join(name)
|
|
166
|
+
path.to_s if path.file? && path.executable?
|
|
167
|
+
end.first
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
module Empeira
|
|
8
|
+
module VM
|
|
9
|
+
# One writable QCOW2 overlay per node; the shared base remains read-only.
|
|
10
|
+
class Disk
|
|
11
|
+
GIB = 1024**3
|
|
12
|
+
|
|
13
|
+
def initialize(engine:, runner:, workspace_directory:)
|
|
14
|
+
@engine = engine
|
|
15
|
+
@runner = runner
|
|
16
|
+
@workspace_directory = workspace_directory
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create(hostname:, base:, size_gib:)
|
|
20
|
+
require_base!(base)
|
|
21
|
+
verify_capacity!(base, size_gib)
|
|
22
|
+
directory = node_directory(hostname)
|
|
23
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
24
|
+
overlay = directory.join('disk.qcow2')
|
|
25
|
+
raise Providers::AlreadyExists, 'VM overlay already exists' if overlay.exist? || overlay.symlink?
|
|
26
|
+
|
|
27
|
+
Tempfile.create(['disk-', '.qcow2'], directory) do |temporary|
|
|
28
|
+
staging = Pathname(temporary.path)
|
|
29
|
+
prepare_overlay!(base, staging, size_gib)
|
|
30
|
+
File.link(staging, overlay)
|
|
31
|
+
end
|
|
32
|
+
overlay
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def verify!(hostname:, base:)
|
|
36
|
+
overlay = node_directory(hostname).join('disk.qcow2')
|
|
37
|
+
raise Error, 'VM overlay is missing or unsafe' unless overlay.file? && !overlay.symlink?
|
|
38
|
+
|
|
39
|
+
verify_backing!(overlay_metadata(overlay), base)
|
|
40
|
+
overlay
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remove(hostname:, base:)
|
|
44
|
+
overlay = verify!(hostname: hostname, base: base)
|
|
45
|
+
File.unlink(overlay)
|
|
46
|
+
overlay
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def prepare_overlay!(base, overlay, size_gib)
|
|
52
|
+
create_overlay!(base, overlay)
|
|
53
|
+
resize_overlay!(overlay, size_gib)
|
|
54
|
+
data = overlay_metadata(overlay)
|
|
55
|
+
verify_backing!(data, base)
|
|
56
|
+
raise Error, 'VM overlay capacity differs from vm.disk' unless data['virtual-size'] == size_gib * GIB
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def verify_backing!(data, base)
|
|
60
|
+
return if data['format'] == 'qcow2' && data['backing-filename'] == base.to_s &&
|
|
61
|
+
data['backing-filename-format'] == 'qcow2'
|
|
62
|
+
|
|
63
|
+
raise Providers::OwnershipError, 'VM overlay backing image differs from recorded base'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_capacity!(base, size_gib)
|
|
67
|
+
raise ConfigurationError, 'vm.disk must be an integer from 1 to 2048 GiB' unless
|
|
68
|
+
size_gib.is_a?(Integer) && (1..2048).cover?(size_gib)
|
|
69
|
+
|
|
70
|
+
capacity = verify_base!(base).fetch('virtual-size', nil)
|
|
71
|
+
raise Error, 'VM base image virtual capacity is missing or invalid' unless
|
|
72
|
+
capacity.is_a?(Integer) && capacity.positive?
|
|
73
|
+
return if size_gib * GIB >= capacity
|
|
74
|
+
|
|
75
|
+
raise ConfigurationError, "vm.disk (#{size_gib} GiB) is smaller than the base image (#{capacity} bytes)"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def require_base!(base)
|
|
79
|
+
raise Error, 'Verified VM base image is missing' unless base.file? && !base.symlink?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def verify_base!(base)
|
|
83
|
+
result = @runner.run(@engine.image_tool, arguments: ['info', '--output=json', base.to_s], timeout: 10)
|
|
84
|
+
verify_result!(result, 'Cannot inspect verified VM base image')
|
|
85
|
+
|
|
86
|
+
metadata = JSON.parse(result.stdout)
|
|
87
|
+
return metadata if metadata.is_a?(Hash) && metadata['format'] == 'qcow2' && !metadata.key?('backing-filename')
|
|
88
|
+
|
|
89
|
+
raise Error, 'VM base image must be a standalone QCOW2 image'
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
raise Error, 'Malformed QEMU base-image metadata', cause: nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def create_overlay!(base, overlay)
|
|
95
|
+
result = @runner.run(@engine.image_tool, arguments: ['create', '-f', 'qcow2', '-F', 'qcow2',
|
|
96
|
+
'-b', base.to_s, overlay.to_s], timeout: 30)
|
|
97
|
+
verify_result!(result, 'QEMU overlay creation failed')
|
|
98
|
+
|
|
99
|
+
File.chmod(0o600, overlay)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resize_overlay!(overlay, size_gib)
|
|
103
|
+
result = @runner.run(@engine.image_tool,
|
|
104
|
+
arguments: ['resize', '--preallocation=off', '-f', 'qcow2', overlay.to_s, "#{size_gib}G"],
|
|
105
|
+
timeout: 30)
|
|
106
|
+
verify_result!(result, 'QEMU overlay sizing failed for vm.disk')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verify_result!(result, operation)
|
|
110
|
+
return if result.success?
|
|
111
|
+
|
|
112
|
+
details = Execution::Diagnostics.command(result, operation: operation, tool: 'qemu-img')
|
|
113
|
+
raise Error, "#{operation}\n#{details}", cause: nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def overlay_metadata(overlay)
|
|
117
|
+
result = @runner.run(@engine.image_tool, arguments: ['info', '--output=json', overlay.to_s], timeout: 10)
|
|
118
|
+
verify_result!(result, 'Cannot inspect VM overlay')
|
|
119
|
+
|
|
120
|
+
data = JSON.parse(result.stdout)
|
|
121
|
+
raise Error, 'Malformed QEMU overlay metadata' unless data.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
data
|
|
124
|
+
rescue JSON::ParserError
|
|
125
|
+
raise Error, 'Malformed QEMU overlay metadata', cause: nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def node_directory(hostname)
|
|
129
|
+
raise Error, 'Invalid VM hostname' unless Node::Inventory.valid_name?(hostname) &&
|
|
130
|
+
hostname == hostname.downcase
|
|
131
|
+
|
|
132
|
+
@workspace_directory.join('vms', hostname)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module VM
|
|
5
|
+
# Shared guest execution/upload/readiness API; no host or guest SSH credentials.
|
|
6
|
+
class Guest
|
|
7
|
+
class TransportError < Error
|
|
8
|
+
attr_reader :timed_out
|
|
9
|
+
|
|
10
|
+
def initialize(message, transient: false, timed_out: false)
|
|
11
|
+
@timed_out = timed_out
|
|
12
|
+
@transient = transient
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def transient? = @transient
|
|
17
|
+
|
|
18
|
+
def with_context(message)
|
|
19
|
+
self.class.new("#{message}\n#{self.message}", transient: transient?, timed_out: timed_out)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(context:, runner:, qemu:)
|
|
24
|
+
@context = context
|
|
25
|
+
@runner = runner
|
|
26
|
+
@qemu = qemu
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(record, arguments, timeout: 30)
|
|
30
|
+
execute(record, arguments, timeout: timeout)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stream(record, arguments, timeout: 3600, output: $stdout, error: $stderr)
|
|
34
|
+
execute(record, arguments, timeout: timeout, output: output, error: error)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def shell(record, input: $stdin, output: $stdout)
|
|
38
|
+
raise Error, 'Node shell requires a local terminal' unless input.tty? && output.tty?
|
|
39
|
+
|
|
40
|
+
transfer(record) { |channel| GuestShell.new(input: input, output: output).consume(channel) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def copy_to(record, source, destination, mode: '0644')
|
|
44
|
+
File.open(source, 'rb') do |file|
|
|
45
|
+
transfer(record) { |channel| GuestUpload.new(channel).write(file, destination, mode: mode) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# rubocop:disable-next Metrics/MethodLength -- Readiness alone retries harmless status probes before bootstrap.
|
|
50
|
+
def wait(record, progress: nil, seconds: 300)
|
|
51
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
52
|
+
loop do
|
|
53
|
+
begin
|
|
54
|
+
result = run(record, ['cloud-init', 'status', '--wait', '--long'], timeout: 20)
|
|
55
|
+
return if result.success?
|
|
56
|
+
|
|
57
|
+
details = Execution::Diagnostics.command(result, operation: 'VM cloud-init readiness',
|
|
58
|
+
tool: 'VirtIO management')
|
|
59
|
+
rescue TransportError => e
|
|
60
|
+
details = e.message
|
|
61
|
+
end
|
|
62
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
63
|
+
raise Error,
|
|
64
|
+
"VM management or cloud-init did not become ready; inspect node shell and serial log\n#{details}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
progress&.heartbeat('Waiting for VM management and cloud-init...')
|
|
68
|
+
sleep 2
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def execute(record, arguments, timeout:, output: nil, error: nil)
|
|
75
|
+
validate_command!(arguments, timeout)
|
|
76
|
+
|
|
77
|
+
transfer(record) do |channel|
|
|
78
|
+
channel.send_frame('type' => 'exec', 'argv' => arguments, 'timeout' => timeout)
|
|
79
|
+
GuestOutput.new(output: output, error: error).consume(channel, timeout: timeout && (timeout + 5))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_command!(arguments, timeout)
|
|
84
|
+
unless arguments.is_a?(Array) && !arguments.empty? && arguments.all? do |arg|
|
|
85
|
+
arg.is_a?(String) && !arg.include?("\0")
|
|
86
|
+
end
|
|
87
|
+
raise ExecutionError, 'Guest command arguments must be nonempty strings without null bytes'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
validate_timeout!(timeout)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_timeout!(timeout)
|
|
94
|
+
return if timeout.nil?
|
|
95
|
+
|
|
96
|
+
return if timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 86_400
|
|
97
|
+
|
|
98
|
+
raise ExecutionError, 'Guest timeout must be positive and at most one day'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# rubocop:disable-next Metrics/MethodLength -- Completion and cancellation must share the same ensure boundary.
|
|
102
|
+
def transfer(record)
|
|
103
|
+
Management.validate!(record)
|
|
104
|
+
GuestChannel.new(record: record, qemu: @qemu).open do |channel|
|
|
105
|
+
completed = false
|
|
106
|
+
begin
|
|
107
|
+
value = yield channel
|
|
108
|
+
completed = true
|
|
109
|
+
value
|
|
110
|
+
ensure
|
|
111
|
+
channel.cancel unless completed
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
rescue SystemCallError, IOError, Timeout::Error => e
|
|
115
|
+
raise TransportError.new("VM VirtIO management transport failed (#{e.class.name}); " \
|
|
116
|
+
'guest completion is unknown; ' \
|
|
117
|
+
'VM retained. Inspect node shell, serial log and the management service journal.',
|
|
118
|
+
transient: e.is_a?(Errno::ENOENT) || e.is_a?(Errno::ECONNREFUSED),
|
|
119
|
+
timed_out: e.is_a?(Timeout::Error)), cause: nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'io/wait'
|
|
6
|
+
|
|
7
|
+
module Empeira
|
|
8
|
+
module VM
|
|
9
|
+
# One private, instance-bound VirtIO-Serial session; never logs protocol payloads.
|
|
10
|
+
class GuestChannel
|
|
11
|
+
LIMIT = 131_072
|
|
12
|
+
CHUNK = 16_384
|
|
13
|
+
|
|
14
|
+
def initialize(record:, qemu:)
|
|
15
|
+
@record = record
|
|
16
|
+
@qemu = qemu
|
|
17
|
+
@id = SecureRandom.hex(16)
|
|
18
|
+
@buffer = +''.b
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def open
|
|
22
|
+
@qemu.with_management_socket(@record) do |path, expected|
|
|
23
|
+
connect(path, expected) { yield self }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def connect(path, expected)
|
|
28
|
+
original = path.lstat
|
|
29
|
+
@socket = UNIXSocket.new(path)
|
|
30
|
+
current = path.lstat
|
|
31
|
+
unless [original.dev, original.ino] == [current.dev, current.ino] &&
|
|
32
|
+
expected == { 'device' => current.dev, 'inode' => current.ino }
|
|
33
|
+
raise Providers::OwnershipError, 'VM management socket changed while connecting'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
handshake!
|
|
37
|
+
|
|
38
|
+
yield self
|
|
39
|
+
ensure
|
|
40
|
+
@socket&.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def readable?
|
|
44
|
+
@buffer.include?("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def io
|
|
48
|
+
@socket
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handshake!
|
|
52
|
+
send_frame('type' => 'hello', 'token' => @record.fetch('peer').fetch('token'))
|
|
53
|
+
response = receive(timeout: 5)
|
|
54
|
+
unless response['type'] == 'hello' && response['version'] == Management::VERSION &&
|
|
55
|
+
response['token'] == @record.fetch('peer').fetch('token')
|
|
56
|
+
raise Providers::OwnershipError, 'VM management identity mismatch; operation refused'
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def send_frame(frame)
|
|
61
|
+
message = "#{JSON.generate(frame.merge('id' => @id))}\n"
|
|
62
|
+
raise Error, 'VM management request is too large' if message.bytesize > LIMIT
|
|
63
|
+
|
|
64
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
65
|
+
until message.empty?
|
|
66
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
raise Timeout::Error if remaining <= 0 || !@socket.wait_writable(remaining)
|
|
68
|
+
|
|
69
|
+
written = @socket.write_nonblock(message, exception: false)
|
|
70
|
+
message = message.byteslice(written..) unless written == :wait_writable
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- Frame size, deadline and session identity are checked together.
|
|
75
|
+
def receive(timeout:)
|
|
76
|
+
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
77
|
+
until @buffer.include?("\n")
|
|
78
|
+
remaining = deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
79
|
+
raise Timeout::Error if (remaining && remaining <= 0) || !@socket.wait_readable(remaining)
|
|
80
|
+
|
|
81
|
+
@buffer << @socket.readpartial(CHUNK)
|
|
82
|
+
raise Error, 'VM management response is oversized' if @buffer.bytesize > LIMIT
|
|
83
|
+
end
|
|
84
|
+
line, @buffer = @buffer.split("\n", 2)
|
|
85
|
+
result = JSON.parse(line)
|
|
86
|
+
unless result.is_a?(Hash) && result['id'] == @id
|
|
87
|
+
raise Providers::OwnershipError, 'VM management response belongs to another operation'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
result
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
raise Error, 'VM management returned malformed protocol data', cause: nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cancel
|
|
96
|
+
send_frame('type' => 'cancel')
|
|
97
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
|
|
98
|
+
loop do
|
|
99
|
+
frame = receive(timeout: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
100
|
+
next unless frame['type'] == 'exit'
|
|
101
|
+
|
|
102
|
+
GuestOutput.new.consume_completion(frame)
|
|
103
|
+
break
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError
|
|
106
|
+
raise Guest::TransportError, 'VM guest session cancellation is unconfirmed; node retained', cause: nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Empeira
|
|
6
|
+
module VM
|
|
7
|
+
# All observations and mutations use the existing guest management transport.
|
|
8
|
+
class GuestNetwork
|
|
9
|
+
attr_reader :links, :addresses, :routes
|
|
10
|
+
|
|
11
|
+
def initialize(guest:, record:)
|
|
12
|
+
@guest = guest
|
|
13
|
+
@record = record
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def refresh
|
|
17
|
+
@links = read(%w[ip -j -d link show]).to_h { |link| [link.fetch('ifname'), link] }
|
|
18
|
+
@addresses = read(%w[ip -j address show]).to_h { |link| [link.fetch('ifname'), link.fetch('addr_info')] }
|
|
19
|
+
@routes = read(%w[ip -j -4 route show table all])
|
|
20
|
+
self
|
|
21
|
+
rescue KeyError, TypeError
|
|
22
|
+
raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: incomplete guest network observation",
|
|
23
|
+
cause: nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load_modules(vlan:)
|
|
27
|
+
# Prevent module autoload from creating an unowned default dummy0.
|
|
28
|
+
command(%w[modprobe dummy numdummies=0]) unless @guest.run(@record, %w[test -d /sys/module/dummy]).success?
|
|
29
|
+
command(%w[modprobe 8021q]) if vlan && !@guest.run(@record, %w[test -d /sys/module/8021q]).success?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def command(arguments)
|
|
33
|
+
result = @guest.run(@record, arguments)
|
|
34
|
+
return result if result.success?
|
|
35
|
+
|
|
36
|
+
details = Execution::Diagnostics.command(result, operation: operation(arguments), tool: arguments.first)
|
|
37
|
+
raise Error, "Cannot reconcile VM interfaces; guest operation failed\n#{details}", cause: nil
|
|
38
|
+
rescue Guest::TransportError => e
|
|
39
|
+
raise e.with_context(operation(arguments)), cause: nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def operation(arguments)
|
|
43
|
+
position = arguments.index('dev') || arguments.index('name')
|
|
44
|
+
interface = position ? ", interface #{arguments[position + 1]}" : ''
|
|
45
|
+
"Host #{@record.fetch('hostname')}#{interface}, vm.interfaces: #{arguments.first}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def read(arguments)
|
|
49
|
+
value = JSON.parse(command(arguments).stdout)
|
|
50
|
+
return value if value.is_a?(Array) && value.all?(Hash)
|
|
51
|
+
|
|
52
|
+
raise TypeError
|
|
53
|
+
rescue JSON::ParserError, TypeError
|
|
54
|
+
raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: cannot verify guest #{arguments.first} JSON",
|
|
55
|
+
cause: nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ipv4(name)
|
|
59
|
+
addresses.fetch(name, []).select { |address| address['family'] == 'inet' }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parent_name(link)
|
|
63
|
+
return link['link'] if link['link'].is_a?(String)
|
|
64
|
+
return unless link['link_index'].is_a?(Integer)
|
|
65
|
+
|
|
66
|
+
links.values.find { |candidate| candidate['ifindex'] == link['link_index'] }&.fetch('ifname')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def defaults
|
|
70
|
+
routes.select { |route| %w[default 0.0.0.0/0].include?(route['dst']) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def verify_facter!(desired)
|
|
74
|
+
result = command(['/opt/puppetlabs/bin/facter', 'networking', '--json'])
|
|
75
|
+
facts = JSON.parse(result.stdout).fetch('networking').fetch('interfaces')
|
|
76
|
+
desired.each do |name, definition|
|
|
77
|
+
binding = facts.dig(name, 'bindings')&.first
|
|
78
|
+
next if correct_binding?(binding, definition.fetch('network'))
|
|
79
|
+
|
|
80
|
+
raise Error, "Host #{@record.fetch('hostname')}, interface #{name}, vm.interfaces: " \
|
|
81
|
+
'regular Facter networking.interfaces bindings do not match; Puppet was not run'
|
|
82
|
+
end
|
|
83
|
+
rescue JSON::ParserError, KeyError, TypeError, NoMethodError
|
|
84
|
+
raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: cannot verify regular Facter networking JSON",
|
|
85
|
+
cause: nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def correct_binding?(binding, network)
|
|
89
|
+
binding.is_a?(Hash) && binding['address'] == network.split('/').first &&
|
|
90
|
+
binding['netmask'] == IPAddr.new(network).netmask.to_s
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module VM
|
|
5
|
+
# Frame output is drained without truncation; streaming sinks receive it before completion.
|
|
6
|
+
class GuestOutput
|
|
7
|
+
def initialize(output: nil, error: nil)
|
|
8
|
+
@stdout = +''.b
|
|
9
|
+
@stderr = +''.b
|
|
10
|
+
@output = output || @stdout
|
|
11
|
+
@error = error || @stderr
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def consume(channel, timeout:)
|
|
15
|
+
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
16
|
+
loop do
|
|
17
|
+
frame = channel.receive(timeout: deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)))
|
|
18
|
+
case frame['type']
|
|
19
|
+
when 'started' then next
|
|
20
|
+
when 'stdout', 'stderr' then emit(frame)
|
|
21
|
+
when 'exit' then return completion(frame)
|
|
22
|
+
else raise Error, 'VM management returned an unexpected protocol message'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
rescue ArgumentError, KeyError
|
|
26
|
+
raise Error, 'VM management returned malformed output data', cause: nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def consume_completion(frame)
|
|
30
|
+
completion(frame)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def emit(frame)
|
|
36
|
+
sink = frame['type'] == 'stdout' ? @output : @error
|
|
37
|
+
sink << Base64.strict_decode64(frame.fetch('data'))
|
|
38
|
+
sink.flush if sink.respond_to?(:flush)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def completion(frame)
|
|
42
|
+
status, timed_out = frame.values_at('status', 'timed_out')
|
|
43
|
+
unless [true, false].include?(timed_out) &&
|
|
44
|
+
((status.is_a?(Integer) && status.between?(0, 255)) || (status.nil? && timed_out))
|
|
45
|
+
raise Error, 'VM management returned invalid guest completion status'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Execution::Result.new(stdout: @stdout, stderr: @stderr, exit_status: status, timed_out: timed_out)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|