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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module VM
|
|
5
|
+
# A guest controlling PTY with local raw-mode restoration; no login or input logging.
|
|
6
|
+
class GuestShell
|
|
7
|
+
def initialize(input:, output:)
|
|
8
|
+
@input = input
|
|
9
|
+
@output = output
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def consume(channel)
|
|
13
|
+
raise Error, 'Node shell requires a local input and output terminal' unless @input.tty? && @output.tty?
|
|
14
|
+
|
|
15
|
+
@channel = channel
|
|
16
|
+
previous = Signal.trap('WINCH') { @resized = true } if Thread.current == Thread.main
|
|
17
|
+
channel.send_frame('type' => 'shell', 'size' => @input.winsize, 'term' => ENV.fetch('TERM', 'xterm'))
|
|
18
|
+
Execution::Terminal.with_interrupts { @input.raw { exchange } }
|
|
19
|
+
ensure
|
|
20
|
+
Signal.trap('WINCH', previous) if previous
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def exchange
|
|
26
|
+
@keyboard = @input
|
|
27
|
+
loop do
|
|
28
|
+
resize
|
|
29
|
+
sources = readable_sources
|
|
30
|
+
if sources.include?(@channel.io) || @channel.readable?
|
|
31
|
+
result = consume_frame
|
|
32
|
+
return result if result
|
|
33
|
+
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
forward_input if @keyboard && sources.include?(@keyboard)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resize
|
|
41
|
+
return unless @resized
|
|
42
|
+
|
|
43
|
+
@channel.send_frame('type' => 'resize', 'size' => @input.winsize)
|
|
44
|
+
@resized = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def readable_sources
|
|
48
|
+
keyboard = @credit ? @keyboard : nil
|
|
49
|
+
IO.select([@channel.io, keyboard].compact, nil, nil, @channel.readable? ? 0 : 0.1)&.first || []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def forward_input
|
|
53
|
+
bytes = @keyboard.read_nonblock(GuestChannel::CHUNK, exception: false)
|
|
54
|
+
return if bytes == :wait_readable
|
|
55
|
+
|
|
56
|
+
@channel.send_frame('type' => bytes ? 'input' : 'eof', 'data' => Base64.strict_encode64(bytes || ''))
|
|
57
|
+
@credit = false
|
|
58
|
+
@keyboard = nil unless bytes
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def consume_frame
|
|
62
|
+
frame = @channel.receive(timeout: 5)
|
|
63
|
+
case frame['type']
|
|
64
|
+
when 'started' then nil
|
|
65
|
+
when 'ready'
|
|
66
|
+
@credit = true
|
|
67
|
+
nil
|
|
68
|
+
when 'stdout'
|
|
69
|
+
@output.write(Base64.strict_decode64(frame.fetch('data')))
|
|
70
|
+
@output.flush
|
|
71
|
+
nil
|
|
72
|
+
when 'exit'
|
|
73
|
+
GuestOutput.new.consume_completion(frame)
|
|
74
|
+
else raise Error, 'VM shell returned an unexpected protocol message'
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module VM
|
|
5
|
+
# Integrity-pinned binary uploads are atomically published by the guest adapter.
|
|
6
|
+
class GuestUpload
|
|
7
|
+
def initialize(channel)
|
|
8
|
+
@channel = channel
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write(file, destination, mode:)
|
|
12
|
+
@channel.send_frame('type' => 'upload')
|
|
13
|
+
ready!
|
|
14
|
+
digest = write_chunks(file)
|
|
15
|
+
@channel.send_frame('type' => 'install', 'destination' => destination, 'mode' => mode,
|
|
16
|
+
'sha256' => digest.hexdigest)
|
|
17
|
+
result = GuestOutput.new.consume(@channel, timeout: 35)
|
|
18
|
+
return if result.success?
|
|
19
|
+
|
|
20
|
+
details = Execution::Diagnostics.command(result, operation: 'VM upload', tool: 'VirtIO management')
|
|
21
|
+
raise Error, "VM upload failed; staging cleaned up, VM retained\n#{details}", cause: nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def write_chunks(file)
|
|
27
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
28
|
+
while (chunk = file.read(GuestChannel::CHUNK))
|
|
29
|
+
digest.update(chunk)
|
|
30
|
+
@channel.send_frame('type' => 'chunk', 'data' => Base64.strict_encode64(chunk))
|
|
31
|
+
ready!
|
|
32
|
+
end
|
|
33
|
+
digest
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ready!
|
|
37
|
+
return if @channel.receive(timeout: 30)['type'] == 'ready'
|
|
38
|
+
|
|
39
|
+
raise Error, 'VM upload protocol did not acknowledge staging'
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'tempfile'
|
|
8
|
+
require 'uri'
|
|
9
|
+
|
|
10
|
+
module Empeira
|
|
11
|
+
module VM
|
|
12
|
+
# Base images are immutable and addressed by all source and checksum fields.
|
|
13
|
+
class ImageCache
|
|
14
|
+
def initialize(locations:, downloader: nil)
|
|
15
|
+
@locations = locations
|
|
16
|
+
@downloader = downloader || method(:download)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(image)
|
|
20
|
+
directory = @locations.image(image.identity)
|
|
21
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
22
|
+
destination = directory.join('base.qcow2')
|
|
23
|
+
return destination if verified?(destination, image.identity.checksum)
|
|
24
|
+
|
|
25
|
+
cache_image(image, directory, destination)
|
|
26
|
+
destination
|
|
27
|
+
rescue SystemCallError, IOError => e
|
|
28
|
+
raise Error, "Cannot cache the verified VM image (#{e.class.name})", cause: nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def cache_image(image, directory, destination)
|
|
34
|
+
Tempfile.create(['vm-image-', '.part'], directory) do |temporary|
|
|
35
|
+
temporary.binmode
|
|
36
|
+
@downloader.call(image.url, temporary)
|
|
37
|
+
temporary.flush
|
|
38
|
+
temporary.fsync
|
|
39
|
+
raise Error, 'Downloaded VM image failed SHA-256 verification' unless
|
|
40
|
+
verified?(temporary.path, image.identity.checksum)
|
|
41
|
+
|
|
42
|
+
temporary.chmod(0o444)
|
|
43
|
+
File.rename(temporary.path, destination)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def verified?(path, checksum)
|
|
48
|
+
File.file?(path) && !File.symlink?(path) && OpenSSL::Digest::SHA256.file(path).hexdigest == checksum
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def download(url, file)
|
|
52
|
+
received = nil
|
|
53
|
+
uri = URI(url)
|
|
54
|
+
raise Error, 'VM image source must use HTTPS' unless uri.is_a?(URI::HTTPS)
|
|
55
|
+
|
|
56
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60) do |http|
|
|
57
|
+
http.request(Net::HTTP::Get.new(uri.request_uri)) do |response|
|
|
58
|
+
received = response
|
|
59
|
+
validate_response!(response, url)
|
|
60
|
+
response.read_body { |chunk| file.write(chunk) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
rescue SocketError, OpenSSL::SSL::SSLError, IOError, SystemCallError, Timeout::Error, Net::ProtocolError => e
|
|
64
|
+
diagnostic = Execution::Diagnostics.transport(operation: 'Download VM base image', url: url, error: e,
|
|
65
|
+
response: received)
|
|
66
|
+
raise Error, "VM image download failed\n#{diagnostic}", cause: nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_response!(response, url)
|
|
70
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
71
|
+
|
|
72
|
+
diagnostic = Execution::Diagnostics.http_response(response, operation: 'Download VM base image', url: url)
|
|
73
|
+
raise Error, "VM image download failed\n#{diagnostic}", cause: nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Empeira
|
|
7
|
+
module VM
|
|
8
|
+
# Resolve moving upstream aliases to a content-addressed image before downloading.
|
|
9
|
+
class ImageSource
|
|
10
|
+
Image = Data.define(:identity, :url, :filename)
|
|
11
|
+
UBUNTU = { '22.04' => 'jammy', '24.04' => 'noble' }.freeze
|
|
12
|
+
ARCHITECTURES = { 'amd64' => 'x86_64', 'arm64' => 'aarch64' }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(fetch_text: nil)
|
|
15
|
+
@fetch_text = fetch_text || method(:read_text)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve(distribution:, version:, architecture:)
|
|
19
|
+
arch = architecture.to_s
|
|
20
|
+
raise UnavailableFeature, 'VM image architecture must be amd64 or arm64' unless ARCHITECTURES.key?(arch)
|
|
21
|
+
|
|
22
|
+
url, manifest = case distribution
|
|
23
|
+
when 'ubuntu' then ubuntu(version, arch)
|
|
24
|
+
when 'rocky' then rocky(version, arch)
|
|
25
|
+
else raise UnavailableFeature, "No verified VM image source for #{distribution} #{version}"
|
|
26
|
+
end
|
|
27
|
+
filename = URI(url).path.split('/').last
|
|
28
|
+
checksum = checksum_for(@fetch_text.call(manifest), filename)
|
|
29
|
+
identity = Images::Identity.new(distribution: distribution, version: version, architecture: arch,
|
|
30
|
+
source: url, revision: checksum, checksum: checksum)
|
|
31
|
+
Image.new(identity: identity, url: url, filename: filename)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def ubuntu(version, arch)
|
|
37
|
+
codename = UBUNTU[version]
|
|
38
|
+
raise UnavailableFeature, "Ubuntu #{version} VM image is not supported" unless codename
|
|
39
|
+
|
|
40
|
+
base = "https://cloud-images.ubuntu.com/releases/#{codename}/release"
|
|
41
|
+
["#{base}/ubuntu-#{version}-server-cloudimg-#{arch}.img", "#{base}/SHA256SUMS"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rocky(version, arch)
|
|
45
|
+
raise UnavailableFeature, "Rocky Linux #{version} VM image is not supported" unless %w[8 9].include?(version)
|
|
46
|
+
|
|
47
|
+
arch_name = ARCHITECTURES.fetch(arch)
|
|
48
|
+
base = "https://dl.rockylinux.org/pub/rocky/#{version}/images/#{arch_name}"
|
|
49
|
+
image = "Rocky-#{version}-GenericCloud-Base.latest.#{arch_name}.qcow2"
|
|
50
|
+
["#{base}/#{image}", "#{base}/#{image}.CHECKSUM"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def checksum_for(text, filename)
|
|
54
|
+
escaped = Regexp.escape(filename)
|
|
55
|
+
ubuntu = /\A([0-9a-f]{64}) \*?#{escaped}\s*\z/i
|
|
56
|
+
rocky = /\ASHA256 \(#{escaped}\) = ([0-9a-f]{64})\s*\z/i
|
|
57
|
+
text.each_line do |line|
|
|
58
|
+
match = ubuntu.match(line)
|
|
59
|
+
return match[1].downcase if match
|
|
60
|
+
|
|
61
|
+
match = rocky.match(line)
|
|
62
|
+
return match[1].downcase if match
|
|
63
|
+
end
|
|
64
|
+
raise Error, 'Upstream image checksum is missing or malformed'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_text(url)
|
|
68
|
+
uri = URI(url)
|
|
69
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
|
|
70
|
+
http.get(uri.request_uri)
|
|
71
|
+
end
|
|
72
|
+
validate_text!(response, url)
|
|
73
|
+
rescue SocketError, OpenSSL::SSL::SSLError, IOError, SystemCallError, Timeout::Error, Net::ProtocolError => e
|
|
74
|
+
diagnostic = Execution::Diagnostics.transport(operation: 'Retrieve VM image checksum', url: url, error: e)
|
|
75
|
+
raise Error, "Cannot retrieve upstream VM image checksum\n#{diagnostic}", cause: nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_text!(response, url)
|
|
79
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
80
|
+
diagnostic = Execution::Diagnostics.http(operation: 'Retrieve VM image checksum', url: url,
|
|
81
|
+
status: response.code, reason: response.message,
|
|
82
|
+
body: response.body, content_type: response['Content-Type'])
|
|
83
|
+
raise Error, "Cannot retrieve upstream VM image checksum\n#{diagnostic}", cause: nil
|
|
84
|
+
end
|
|
85
|
+
raise Error, 'Upstream VM image checksum is too large' if response.body.bytesize > 100_000
|
|
86
|
+
|
|
87
|
+
response.body
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module VM
|
|
5
|
+
class InterfaceOwnership
|
|
6
|
+
PARENT = Configuration::VMInterfaces::PARENT
|
|
7
|
+
|
|
8
|
+
def initialize(context:, record:, inventory:, guest:)
|
|
9
|
+
@record = record
|
|
10
|
+
@inventory = inventory
|
|
11
|
+
@guest = guest
|
|
12
|
+
node = Digest::SHA256.hexdigest(record.fetch('hostname'))[0, 16]
|
|
13
|
+
@owner = "empeira:#{context.workspace.id}:#{node}:#{inventory.fetch('token')}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def marker(name, definition)
|
|
17
|
+
"#{@owner}:#{name}:#{Infrastructure::Definition.fingerprint(definition)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def address(name, definition)
|
|
21
|
+
['02', *Digest::SHA256.hexdigest(marker(name, definition))[0, 10].scan(/../)].join(':')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def accepted(name)
|
|
25
|
+
return [{}] if name == PARENT && @inventory.fetch('parent')
|
|
26
|
+
|
|
27
|
+
entry = @inventory.fetch('devices')[name]
|
|
28
|
+
entry ? [entry.fetch('definition'), entry['previous']].compact.uniq : []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def observed_definition(name)
|
|
32
|
+
link = @guest.links[name]
|
|
33
|
+
return unless link
|
|
34
|
+
|
|
35
|
+
definition = accepted(name).find { |candidate| marker_matches?(name, link, candidate) }
|
|
36
|
+
unless definition
|
|
37
|
+
conflict!(name,
|
|
38
|
+
'existing interface is foreign: MAC/alias ownership marker does not match recorded intent')
|
|
39
|
+
end
|
|
40
|
+
unless correct_type?(name, link, definition) && !link.key?('master') && !link.key?('link_netnsid')
|
|
41
|
+
conflict!(name, 'existing interface is foreign, has changed type/parent, or is attached to another network')
|
|
42
|
+
end
|
|
43
|
+
verify_addresses!(name)
|
|
44
|
+
definition
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def marker_matches?(name, link, definition)
|
|
48
|
+
link['address'] == address(name, definition) &&
|
|
49
|
+
(link.fetch('ifalias', '').empty? || link['ifalias'] == marker(name, definition))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def correct_type?(name, link, definition)
|
|
53
|
+
info = link.fetch('linkinfo', {})
|
|
54
|
+
return info['info_kind'] == 'dummy' if name == PARENT || !definition.key?('vlan_id')
|
|
55
|
+
|
|
56
|
+
correct_vlan?(link, info, definition)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def correct_vlan?(link, info, definition)
|
|
60
|
+
parent = @guest.links[PARENT]
|
|
61
|
+
info['info_kind'] == 'vlan' && info.dig('info_data', 'id') == definition.fetch('vlan_id') &&
|
|
62
|
+
info.dig('info_data', 'protocol').to_s.downcase == '802.1q' && parent && @guest.parent_name(link) == PARENT
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verify_addresses!(name)
|
|
66
|
+
addresses = @guest.addresses.fetch(name, [])
|
|
67
|
+
networks = accepted(name).filter_map { |definition| definition['network'] }
|
|
68
|
+
return if addresses.all? do |address|
|
|
69
|
+
address['family'] == 'inet' && networks.include?("#{address['local']}/#{address['prefixlen']}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
conflict!(name, 'interface contains an unowned address; preserve it and investigate')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate!(names)
|
|
76
|
+
names.each { |name| observed_definition(name) }
|
|
77
|
+
validate_markers!(names)
|
|
78
|
+
validate_dependencies!(names)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_markers!(names)
|
|
82
|
+
@guest.links.each do |name, link|
|
|
83
|
+
next unless owned_marker?(link, names)
|
|
84
|
+
next if names.include?(name)
|
|
85
|
+
|
|
86
|
+
conflict!(name, 'owned marker appears on an unrecorded or renamed interface')
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def owned_marker?(link, names)
|
|
91
|
+
link.fetch('ifalias', '').start_with?("#{@owner}:") || names.any? do |name|
|
|
92
|
+
accepted(name).any? { |definition| link['address'] == address(name, definition) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_dependencies!(names)
|
|
97
|
+
names.each do |name|
|
|
98
|
+
link = @guest.links[name]
|
|
99
|
+
next unless link
|
|
100
|
+
|
|
101
|
+
children = @guest.links.values.select { |child| @guest.parent_name(child) == name }
|
|
102
|
+
children.each do |child|
|
|
103
|
+
child_name = child.fetch('ifname')
|
|
104
|
+
conflict!(name, "dependent interface #{child_name} is foreign") unless names.include?(child_name)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def deletable!(name)
|
|
110
|
+
observed_definition(name)
|
|
111
|
+
link = @guest.links[name]
|
|
112
|
+
return unless link
|
|
113
|
+
return unless @guest.links.values.any? { |child| @guest.parent_name(child) == name }
|
|
114
|
+
|
|
115
|
+
conflict!(name, 'dependent interfaces still exist; parent deletion refused')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def conflict!(name, reason)
|
|
119
|
+
raise Providers::OwnershipError,
|
|
120
|
+
"Host #{@record.fetch('hostname')}, interface #{name}, vm.interfaces: #{reason}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module VM
|
|
5
|
+
class InterfaceRoutes
|
|
6
|
+
def self.validate_static!(desired, hostname:, config:, subnet:)
|
|
7
|
+
protected = [subnet].compact + config.dig('network', 'redirects').map do |entry|
|
|
8
|
+
entry.fetch('from').fetch('ip')
|
|
9
|
+
end
|
|
10
|
+
desired.each do |name, definition|
|
|
11
|
+
protected.each { |range| reject_overlap!(definition.fetch('network'), range, hostname, name) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.reject_overlap!(network, range, hostname, name)
|
|
16
|
+
return unless Configuration::VMInterfaces.overlap?(IPAddr.new(network), IPAddr.new(range))
|
|
17
|
+
|
|
18
|
+
raise ConfigurationError, "Host #{hostname}, interface #{name}, vm.interfaces: network #{network} " \
|
|
19
|
+
"conflicts with protected route/address #{range} (including network.redirects)"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(guest:, ownership:, hostname:)
|
|
23
|
+
@guest = guest
|
|
24
|
+
@ownership = ownership
|
|
25
|
+
@hostname = hostname
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate!(desired)
|
|
29
|
+
protected = foreign_addresses + foreign_routes
|
|
30
|
+
desired.each do |name, definition|
|
|
31
|
+
protected.each { |range| self.class.reject_overlap!(definition.fetch('network'), range, @hostname, name) }
|
|
32
|
+
end
|
|
33
|
+
rescue IPAddr::InvalidAddressError, KeyError, TypeError
|
|
34
|
+
raise Error, "Host #{@hostname}, vm.interfaces: guest routes/addresses cannot be verified", cause: nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def foreign_addresses
|
|
38
|
+
@guest.addresses.flat_map do |name, addresses|
|
|
39
|
+
next [] unless @ownership.accepted(name).empty?
|
|
40
|
+
|
|
41
|
+
addresses.filter_map do |address|
|
|
42
|
+
"#{address.fetch('local')}/#{address.fetch('prefixlen')}" if address['family'] == 'inet'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def foreign_routes
|
|
48
|
+
@guest.routes.filter_map do |route|
|
|
49
|
+
raise Error, "Host #{@hostname}, vm.interfaces: route destination is missing" if route['dst'].nil?
|
|
50
|
+
raise Error, "Host #{@hostname}, vm.interfaces: unresolved route nexthop ID" if route.key?('nhid')
|
|
51
|
+
next if foreign_default?(route)
|
|
52
|
+
next if automatic_owned_route?(route)
|
|
53
|
+
|
|
54
|
+
owned = owned_route_device(route)
|
|
55
|
+
@ownership.conflict!(owned, 'interface carries a non-generated route; mutation refused') if owned
|
|
56
|
+
|
|
57
|
+
route.fetch('dst')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def foreign_default?(route)
|
|
62
|
+
%w[default 0.0.0.0/0].include?(route['dst']) && owned_route_device(route).nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def owned_route_device(route)
|
|
66
|
+
hops = route.values_at('multipath', 'nexthops').compact.flatten
|
|
67
|
+
devices = [route['dev'], *hops.map { |hop| hop.fetch('dev') }].compact
|
|
68
|
+
devices.find { |name| !@ownership.accepted(name).empty? }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def automatic_owned_route?(route)
|
|
72
|
+
return false unless ordinary_kernel_route?(route)
|
|
73
|
+
|
|
74
|
+
@ownership.accepted(route['dev']).any? do |definition|
|
|
75
|
+
next false unless definition['network']
|
|
76
|
+
|
|
77
|
+
generated_route?(route, definition.fetch('network'))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ordinary_kernel_route?(route)
|
|
82
|
+
route['protocol'] == 'kernel' && !route.key?('gateway') && !route.key?('multipath') &&
|
|
83
|
+
!route.key?('nexthops') && [nil, 'main', 'local', 254, 255].include?(route['table'])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def generated_route?(route, network)
|
|
87
|
+
subnet = IPAddr.new(network)
|
|
88
|
+
target = IPAddr.new(route.fetch('dst'))
|
|
89
|
+
case route['type']
|
|
90
|
+
when 'local' then target.prefix == 32 && target.to_s == network.split('/').first
|
|
91
|
+
when 'broadcast' then broadcast?(target, subnet)
|
|
92
|
+
else route['scope'] == 'link' && target == subnet && target.prefix == subnet.prefix
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def broadcast?(target, subnet)
|
|
97
|
+
target.prefix == 32 && [subnet.to_range.first.to_s, subnet.to_range.last.to_s].include?(target.to_s)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|