metasploitable3/chef/cookbooks/docker/libraries/docker_container.rb
2019-02-17 00:02:05 -06:00

764 lines
29 KiB
Ruby

module DockerCookbook
class DockerContainer < DockerBase
resource_name :docker_container
property :container_name, String, name_property: true
property :repo, String, default: lazy { container_name }
property :tag, String, default: 'latest'
property :command, [Array, String, nil], coerce: proc { |v| v.is_a?(String) ? ::Shellwords.shellwords(v) : v }
property :attach_stderr, [TrueClass, FalseClass], default: false, desired_state: false
property :attach_stdin, [TrueClass, FalseClass], default: false, desired_state: false
property :attach_stdout, [TrueClass, FalseClass], default: false, desired_state: false
property :autoremove, [TrueClass, FalseClass], default: false, desired_state: false
property :cap_add, [Array, nil], coerce: proc { |v| Array(v).empty? ? nil : Array(v) }
property :cap_drop, [Array, nil], coerce: proc { |v| Array(v).empty? ? nil : Array(v) }
property :cgroup_parent, String, default: ''
property :cpu_shares, Integer, default: 0
property :cpuset_cpus, String, default: ''
property :detach, [TrueClass, FalseClass], default: true, desired_state: false
property :devices, Array, default: []
property :dns, Array, default: []
property :dns_search, Array, default: []
property :domain_name, String, default: ''
property :entrypoint, [Array, String, nil], coerce: proc { |v| v.is_a?(String) ? ::Shellwords.shellwords(v) : v }
property :env, UnorderedArrayType, default: []
property :env_file, [Array, String], coerce: proc { |v| coerce_env_file(v) }, default: [], desired_state: false
property :extra_hosts, [Array, nil], coerce: proc { |v| Array(v).empty? ? nil : Array(v) }
property :exposed_ports, PartialHashType, default: {}
property :force, [TrueClass, FalseClass], default: false, desired_state: false
property :health_check, Hash, default: {}
property :host, [String, nil], default: lazy { ENV['DOCKER_HOST'] }, desired_state: false
property :hostname, String
property :ipc_mode, String, default: ''
property :kernel_memory, [String, Integer], coerce: proc { |v| coerce_to_bytes(v) }, default: 0
property :labels, [String, Array, Hash], default: {}, coerce: proc { |v| coerce_labels(v) }
property :links, UnorderedArrayType, coerce: proc { |v| coerce_links(v) }
property :log_driver, %w( json-file syslog journald gelf fluentd awslogs splunk etwlogs gcplogs none ), default: 'json-file', desired_state: false
property :log_opts, [Hash, nil], coerce: proc { |v| coerce_log_opts(v) }, desired_state: false
property :init, [TrueClass, FalseClass, nil]
property :ip_address, String
property :mac_address, String
property :memory, [String, Integer], coerce: proc { |v| coerce_to_bytes(v) }, default: 0
property :memory_swap, [String, Integer], coerce: proc { |v| coerce_to_bytes(v) }, default: 0
property :memory_swappiness, Integer, default: 0
property :memory_reservation, Integer, coerce: proc { |v| coerce_to_bytes(v) }, default: 0
property :network_disabled, [TrueClass, FalseClass], default: false
property :network_mode, String, default: 'bridge'
property :network_aliases, [String, Array], default: [], coerce: proc { |v| Array(v) }
property :oom_kill_disable, [TrueClass, FalseClass], default: false
property :oom_score_adj, Integer, default: -500
property :open_stdin, [TrueClass, FalseClass], default: false, desired_state: false
property :outfile, String
property :port_bindings, PartialHashType, default: {}
property :pid_mode, String, default: ''
property :privileged, [TrueClass, FalseClass], default: false
property :publish_all_ports, [TrueClass, FalseClass], default: false
property :remove_volumes, [TrueClass, FalseClass], default: false
property :restart_maximum_retry_count, Integer, default: 0
property :restart_policy, String
property :runtime, String, default: 'runc'
property :ro_rootfs, [TrueClass, FalseClass], default: false
property :security_opt, [String, Array], coerce: proc { |v| v.nil? ? nil : Array(v) }
property :shm_size, [String, Integer], default: '64m', coerce: proc { |v| coerce_to_bytes(v) }
property :signal, String, default: 'SIGTERM'
property :stdin_once, [TrueClass, FalseClass], default: false, desired_state: false
property :sysctls, Hash, default: {}
property :timeout, Integer, desired_state: false
property :tty, [TrueClass, FalseClass], default: false
property :ulimits, [Array, nil], coerce: proc { |v| coerce_ulimits(v) }
property :user, String, default: ''
property :userns_mode, String, default: ''
property :uts_mode, String, default: ''
property :volumes, PartialHashType, default: {}, coerce: proc { |v| coerce_volumes(v) }
property :volumes_from, [String, Array], coerce: proc { |v| v.nil? ? nil : Array(v) }
property :volume_driver, String
property :working_dir, String, default: ''
# Used to store the bind property since binds is an alias to volumes
property :volumes_binds, Array
# Used to store the state of the Docker container
property :container, Docker::Container, desired_state: false
# Used to store the state of the Docker container create options
property :create_options, Hash, default: {}, desired_state: false
# Used by :stop action. If the container takes longer than this
# many seconds to stop, kill it instead. A nil value (the default) means
# never kill the container.
property :kill_after, [Integer, NilClass], default: nil, desired_state: false
alias_method :cmd, :command
alias_method :additional_host, :extra_hosts
alias_method :rm, :autoremove
alias_method :remove_automatically, :autoremove
alias_method :host_name, :hostname
alias_method :domainname, :domain_name
alias_method :dnssearch, :dns_search
alias_method :restart_maximum_retries, :restart_maximum_retry_count
alias_method :volume, :volumes
alias_method :binds, :volumes
alias_method :volume_from, :volumes_from
alias_method :destination, :outfile
alias_method :workdir, :working_dir
###################
# Property helpers
###################
def coerce_labels(v)
case v
when Hash, nil
v
else
Array(v).each_with_object({}) do |label, h|
parts = label.split(':')
h[parts[0]] = parts[1..-1].join(':')
end
end
end
def coerce_links(v)
case v
when DockerBase::UnorderedArray, nil
v
else
return nil if v.empty?
# Parse docker input of /source:/container_name/dest into source:dest
DockerBase::UnorderedArray.new(Array(v)).map! do |link|
if link =~ %r{^/(?<source>.+):/#{name}/(?<dest>.+)}
link = "#{Regexp.last_match[:source]}:#{Regexp.last_match[:dest]}"
end
link
end
end
end
def to_bytes(v)
n = v.to_i
u = v.gsub(/\d/, '').upcase
multiplier = case u
when 'B'
1
when 'K'
1024**1
when 'M'
1024**2
when 'G'
1024**3
when 'T'
1024**4
when 'P'
1024**5
when 'E'
1024**6
when 'Z'
1024**7
when 'Y'
1024**8
else
1
end
n * multiplier
end
def coerce_to_bytes(v)
case v
when Integer, nil
v
else
to_bytes(v)
end
end
def coerce_log_opts(v)
case v
when Hash, nil
v
else
Array(v).each_with_object({}) do |log_opt, memo|
key, value = log_opt.split('=', 2)
memo[key] = value
end
end
end
def coerce_ulimits(v)
return v if v.nil?
Array(v).map do |u|
u = "#{u['Name']}=#{u['Soft']}:#{u['Hard']}" if u.is_a?(Hash)
u
end
end
def coerce_volumes(v)
case v
when DockerBase::PartialHash, nil
v
when Hash
DockerBase::PartialHash[v]
else
b = []
v = Array(v).to_a # in case v.is_A?(Chef::Node::ImmutableArray)
v.delete_if do |x|
parts = x.split(':')
b << x if parts.length > 1
end
b = nil if b.empty?
volumes_binds b
return DockerBase::PartialHash.new if v.empty?
v.each_with_object(DockerBase::PartialHash.new) { |volume, h| h[volume] = {} }
end
end
def state
# Always return the latest state, see #510
Docker::Container.get(container_name, {}, connection).info['State']
rescue StandardError
{}
end
def wait_running_state(v)
tries = running_wait_time
tries.times do
return if state['Running'] == v
sleep 1
end
return if state['Running'] == v
# Container failed to reach correct state: Throw an error
desired_state_str = v ? 'running' : 'not running'
raise Docker::Error::TimeoutError, "Container #{container_name} failed to change to #{desired_state_str} state after #{tries} seconds"
end
def port(v = nil)
return @port if v.nil?
exposed_ports coerce_exposed_ports(v)
port_bindings coerce_port_bindings(v)
@port = v
@port
end
def parse_port(v)
_, protocol = v.split('/')
parts = v.split(':')
case parts.length
when 3
host_ip = parts[0]
host_port = parts[1].split('-')
container_port = parts[2].split('-')
when 2
host_ip = '0.0.0.0'
host_port = parts[0].split('-')
container_port = parts[1].split('-')
when 1
host_ip = ''
host_port = ['']
container_port = parts[0].split('-')
end
host_port.map!(&:to_i) unless host_port == ['']
container_port.map!(&:to_i)
if host_port.count > 1
Chef::Log.fatal("FATAL: Invalid port range! #{host_port}") if host_port[0] > host_port[1]
host_port = (host_port[0]..host_port[1]).to_a
end
if container_port.count > 1
Chef::Log.fatal("FATAL: Invalid port range! #{container_port}") if container_port[0] > container_port[1]
container_port = (container_port[0]..container_port[1]).to_a
end
Chef::Log.fatal('FATAL: Port range size does not match!') if host_port.count > 1 && host_port.count != container_port.count
# qualify the port-binding protocol even when it is implicitly tcp #427.
protocol = 'tcp' if protocol.nil?
Array(container_port).map.with_index do |_, i|
{
'host_ip' => host_ip,
'host_port' => host_port[i].to_s,
'container_port' => "#{container_port[i]}/#{protocol}",
}
end
end
def coerce_exposed_ports(v)
case v
when Hash, nil
v
else
x = Array(v).map { |a| parse_port(a) }
x.flatten!
x.each_with_object({}) do |y, h|
h[y['container_port']] = {}
end
end
end
def coerce_port_bindings(v)
case v
when Hash, nil
v
else
x = Array(v).map { |a| parse_port(a) }
x.flatten!
x.each_with_object({}) do |y, h|
h[y['container_port']] = [] unless h[y['container_port']]
h[y['container_port']] << {
'HostIp' => y['host_ip'],
'HostPort' => y['host_port'],
}
end
end
end
def coerce_env_file(v)
return v if v.empty?
Array(v).map { |f| ::File.readlines(f).map(&:strip) }.flatten
end
# log_driver and log_opts really handle this
def log_config(value = Chef::NOT_PASSED)
if value != Chef::NOT_PASSED
@log_config = value
log_driver value['Type']
log_opts value['Config']
end
return @log_config if defined?(@log_config)
def_logcfg = {}
def_logcfg['Type'] = log_driver if property_is_set?(:log_driver)
def_logcfg['Config'] = log_opts if property_is_set?(:log_opts)
def_logcfg = nil if def_logcfg.empty?
def_logcfg
end
# TODO: test image property in serverspec and kitchen, not only in rspec
# for full specs of image parsing, see spec/helpers_container_spec.rb
#
# If you say: `repo 'blah'`
# Image will be: `blah:latest`
#
# If you say: `repo 'blah'; tag '3.1'`
# Image will be: `blah:3.1`
#
# If you say: `image 'blah'`
# Repo will be: `blah`
# Tag will be: `latest`
#
# If you say: `image 'blah:3.1'`
# Repo will be: `blah`
# Tag will be: `3.1`
#
# If you say: `image 'repo/blah'`
# Repo will be: `repo/blah`
# Tag will be: `latest`
#
# If you say: `image 'repo/blah:3.1'`
# Repo will be: `repo/blah`
# Tag will be: `3.1`
#
# If you say: `image 'repo:1337/blah'`
# Repo will be: `repo:1337/blah`
# Tag will be: `latest'
#
# If you say: `image 'repo:1337/blah:3.1'`
# Repo will be: `repo:1337/blah`
# Tag will be: `3.1`
#
def image(image = nil)
if image
if image.include?('/')
# pathological case, a ':' may be present which starts the 'port'
# part of the image name and not a tag. example: 'host:1337/blah'
# fortunately, tags are only found in the 'basename' part of image
# so we can split on '/' and rebuild once the tag has been parsed.
dirname, _, basename = image.rpartition('/')
r, t = basename.split(':', 2)
r = [dirname, r].join('/')
else
# normal case, the ':' starts the tag part
r, t = image.split(':', 2)
end
repo r
tag t if t
end
"#{repo}:#{tag}"
end
def to_shellwords(command)
command.is_a?(String) ? ::Shellwords.shellwords(command) : command
end
######################
# Load Current Value
######################
def to_snake_case(name)
# ExposedPorts -> _exposed_ports
name = name.gsub(/[A-Z]/) { |x| "_#{x.downcase}" }
# _exposed_ports -> exposed_ports
name = name[1..-1] if name.start_with?('_')
name
end
load_current_value do
# Grab the container and assign the container property
begin
with_retries { container Docker::Container.get(container_name, {}, connection) }
rescue Docker::Error::NotFoundError
current_value_does_not_exist!
end
# Go through everything in the container and set corresponding properties:
# c.info['Config']['ExposedPorts'] -> exposed_ports
(container.info['Config'].to_a + container.info['HostConfig'].to_a).each do |key, value|
next if value.nil? || key == 'RestartPolicy' || key == 'Binds' || key == 'ReadonlyRootfs'
# Image => image
# Set exposed_ports = ExposedPorts (etc.)
property_name = to_snake_case(key)
public_send(property_name, value) if respond_to?(property_name)
end
# load container specific labels (without engine/image ones)
load_container_labels
# these are a special case for us because our names differ from theirs
restart_policy container.info['HostConfig']['RestartPolicy']['Name']
restart_maximum_retry_count container.info['HostConfig']['RestartPolicy']['MaximumRetryCount']
volumes_binds container.info['HostConfig']['Binds']
ro_rootfs container.info['HostConfig']['ReadonlyRootfs']
ip_address ip_address_from_container_networks(container) unless ip_address_from_container_networks(container).nil?
end
# Gets the ip address from the existing container
# current docker api of 1.16 does not have ['NetworkSettings']['Networks']
# For docker > 1.21 - use ['NetworkSettings']['Networks']
#
# @param container [Docker::Container] A container object
# @returns [String] An ip_address
def ip_address_from_container_networks(container)
# We use the first value in 'Networks'
# We can't assume it will be 'bridged'
# It might also not match the new_resource value
if container.info['NetworkSettings'] &&
container.info['NetworkSettings']['Networks'] &&
container.info['NetworkSettings']['Networks'].values[0] &&
container.info['NetworkSettings']['Networks'].values[0]['IPAMConfig'] &&
container.info['NetworkSettings']['Networks'].values[0]['IPAMConfig']['IPv4Address']
# Return the ip address listed
container.info['NetworkSettings']['Networks'].values[0]['IPAMConfig']['IPv4Address']
end
end
#########
# Actions
#########
# Super handy visual reference!
# http://gliderlabs.com/images/docker_events.png
# Loads container specific labels excluding those of engine or image.
# This insures idempotency.
def load_container_labels
image_labels = Docker::Image.get(container.info['Image'], {}, connection).info['Config']['Labels'] || {}
engine_labels = Docker.info(connection)['Labels'] || {}
labels = (container.info['Config']['Labels'] || {}).reject do |key, val|
image_labels.any? { |k, v| k == key && v == val } ||
engine_labels.any? { |k, v| k == key && v == val }
end
public_send(:labels, labels)
end
action :run do
validate_container_create
call_action(:create)
call_action(:start)
call_action(:delete) if new_resource.autoremove
end
action :create do
validate_container_create
converge_if_changed do
action_delete
with_retries do
config = {
'name' => new_resource.container_name,
'Image' => "#{new_resource.repo}:#{new_resource.tag}",
'Labels' => new_resource.labels,
'Cmd' => to_shellwords(new_resource.command),
'AttachStderr' => new_resource.attach_stderr,
'AttachStdin' => new_resource.attach_stdin,
'AttachStdout' => new_resource.attach_stdout,
'Domainname' => new_resource.domain_name,
'Entrypoint' => to_shellwords(new_resource.entrypoint),
'Env' => new_resource.env + new_resource.env_file,
'ExposedPorts' => new_resource.exposed_ports,
'Hostname' => parsed_hostname,
'MacAddress' => new_resource.mac_address,
'NetworkDisabled' => new_resource.network_disabled,
'OpenStdin' => new_resource.open_stdin,
'StdinOnce' => new_resource.stdin_once,
'Tty' => new_resource.tty,
'User' => new_resource.user,
'Volumes' => new_resource.volumes,
'WorkingDir' => new_resource.working_dir,
'HostConfig' => {
'Binds' => new_resource.volumes_binds,
'CapAdd' => new_resource.cap_add,
'CapDrop' => new_resource.cap_drop,
'CgroupParent' => new_resource.cgroup_parent,
'CpuShares' => new_resource.cpu_shares,
'CpusetCpus' => new_resource.cpuset_cpus,
'Devices' => new_resource.devices,
'Dns' => new_resource.dns,
'DnsSearch' => new_resource.dns_search,
'ExtraHosts' => new_resource.extra_hosts,
'IpcMode' => new_resource.ipc_mode,
'Init' => new_resource.init,
'KernelMemory' => new_resource.kernel_memory,
'Links' => new_resource.links,
'LogConfig' => log_config,
'Memory' => new_resource.memory,
'MemorySwap' => new_resource.memory_swap,
'MemorySwappiness' => new_resource.memory_swappiness,
'MemoryReservation' => new_resource.memory_reservation,
'NetworkMode' => new_resource.network_mode,
'OomKillDisable' => new_resource.oom_kill_disable,
'OomScoreAdj' => new_resource.oom_score_adj,
'Privileged' => new_resource.privileged,
'PidMode' => new_resource.pid_mode,
'PortBindings' => new_resource.port_bindings,
'PublishAllPorts' => new_resource.publish_all_ports,
'RestartPolicy' => {
'Name' => new_resource.restart_policy,
'MaximumRetryCount' => new_resource.restart_maximum_retry_count,
},
'ReadonlyRootfs' => new_resource.ro_rootfs,
'Runtime' => new_resource.runtime,
'SecurityOpt' => new_resource.security_opt,
'ShmSize' => new_resource.shm_size,
'Sysctls' => new_resource.sysctls,
'Ulimits' => ulimits_to_hash,
'UsernsMode' => new_resource.userns_mode,
'UTSMode' => new_resource.uts_mode,
'VolumesFrom' => new_resource.volumes_from,
'VolumeDriver' => new_resource.volume_driver,
},
}
net_config = {
'NetworkingConfig' => {
'EndpointsConfig' => {
new_resource.network_mode => {
'IPAMConfig' => {
'IPv4Address' => new_resource.ip_address,
},
'Aliases' => new_resource.network_aliases,
},
},
},
} if new_resource.network_mode
config.merge! net_config
# Remove any options not supported in windows
if platform?('windows')
config['HostConfig'].delete('MemorySwappiness')
end
unless new_resource.health_check.empty?
config['Healthcheck'] = new_resource.health_check
end
# Store the state of the options and create the container
new_resource.create_options = config
Docker::Container.create(config, connection)
end
end
end
action :start do
return if state['Restarting']
return if state['Running']
converge_by "starting #{new_resource.container_name}" do
with_retries do
current_resource.container.start
unless new_resource.detach
new_resource.timeout ? current_resource.container.wait(new_resource.timeout) : current_resource.container.wait
end
end
wait_running_state(true) if new_resource.detach
end
end
action :stop do
return unless state['Running']
kill_after_str = "(will kill after #{new_resource.kill_after}s)" if new_resource.kill_after
converge_by "stopping #{new_resource.container_name} #{kill_after_str}" do
begin
with_retries do
current_resource.container.stop!('timeout' => new_resource.kill_after)
wait_running_state(false)
end
rescue Docker::Error::TimeoutError
raise Docker::Error::TimeoutError, "Container failed to stop, consider adding kill_after to the container #{new_resource.container_name}"
end
end
end
action :kill do
return unless state['Running']
converge_by "killing #{new_resource.container_name}" do
with_retries { current_resource.container.kill(signal: new_resource.signal) }
end
end
action :run_if_missing do
return if current_resource
call_action(:run)
end
action :pause do
return if state['Paused']
converge_by "pausing #{new_resource.container_name}" do
with_retries { current_resource.container.pause }
end
end
action :unpause do
return if current_resource && !state['Paused']
converge_by "unpausing #{new_resource.container_name}" do
with_retries { current_resource.container.unpause }
end
end
action :restart do
kill_after_str = " (will kill after #{new_resource.kill_after}s)" if new_resource.kill_after != -1
converge_by "restarting #{new_resource.container_name} #{kill_after_str}" do
current_resource ? current_resource.container.restart('timeout' => new_resource.kill_after) : call_action(:run)
end
end
action :reload do
converge_by "reloading #{new_resource.container_name}" do
with_retries { current_resource.container.kill(signal: 'SIGHUP') }
end
end
action :redeploy do
validate_container_create
# never start containers resulting from a previous action :create #432
should_create = state['Running'] == false && state['StartedAt'] == '0001-01-01T00:00:00Z'
call_action(:delete)
call_action(should_create ? :create : :run)
end
action :delete do
return unless current_resource
call_action(:unpause)
call_action(:stop)
converge_by "deleting #{new_resource.container_name}" do
with_retries { current_resource.container.delete(force: new_resource.force, v: new_resource.remove_volumes) }
end
end
action :remove do
call_action(:delete)
end
action :commit do
converge_by "committing #{new_resource.container_name}" do
with_retries do
new_image = current_resource.container.commit
new_image.tag('repo' => new_resource.repo, 'tag' => new_resource.tag, 'force' => new_resource.force)
end
end
end
action :export do
raise "Please set outfile property on #{new_resource.container_name}" if new_resource.outfile.nil?
converge_by "exporting #{new_resource.container_name}" do
with_retries do
::File.open(new_resource.outfile, 'w') { |f| current_resource.container.export { |chunk| f.write(chunk) } }
end
end
end
declare_action_class.class_eval do
def validate_container_create
if new_resource.property_is_set?(:restart_policy) &&
new_resource.restart_policy != 'no' &&
new_resource.restart_policy != 'always' &&
new_resource.restart_policy != 'unless-stopped' &&
new_resource.restart_policy != 'on-failure'
raise Chef::Exceptions::ValidationFailed, 'restart_policy must be either no, always, unless-stopped, or on-failure.'
end
if new_resource.autoremove == true && (new_resource.property_is_set?(:restart_policy) && restart_policy != 'no')
raise Chef::Exceptions::ValidationFailed, 'Conflicting options restart_policy and autoremove.'
end
if new_resource.detach == true &&
(
new_resource.attach_stderr == true ||
new_resource.attach_stdin == true ||
new_resource.attach_stdout == true ||
new_resource.stdin_once == true
)
raise Chef::Exceptions::ValidationFailed, 'Conflicting options detach, attach_stderr, attach_stdin, attach_stdout, stdin_once.'
end
if new_resource.network_mode == 'host' &&
(
!(new_resource.hostname.nil? || new_resource.hostname.empty?) ||
!(new_resource.mac_address.nil? || new_resource.mac_address.empty?)
)
raise Chef::Exceptions::ValidationFailed, 'Cannot specify hostname or mac_address when network_mode is host.'
end
if new_resource.network_mode == 'container' &&
(
!(new_resource.hostname.nil? || new_resource.hostname.empty?) ||
!(new_resource.dns.nil? || new_resource.dns.empty?) ||
!(new_resource.dns_search.nil? || new_resource.dns_search.empty?) ||
!(new_resource.mac_address.nil? || new_resource.mac_address.empty?) ||
!(new_resource.extra_hosts.nil? || new_resource.extra_hosts.empty?) ||
!(new_resource.exposed_ports.nil? || new_resource.exposed_ports.empty?) ||
!(new_resource.port_bindings.nil? || new_resource.port_bindings.empty?) ||
!(new_resource.publish_all_ports.nil? || new_resource.publish_all_ports.empty?) ||
!new_resource.port.nil?
)
raise Chef::Exceptions::ValidationFailed, 'Cannot specify hostname, dns, dns_search, mac_address, extra_hosts, exposed_ports, port_bindings, publish_all_ports, port when network_mode is container.'
end
end
def parsed_hostname
return nil if new_resource.network_mode == 'host'
new_resource.hostname
end
def call_action(action)
send("action_#{action}")
load_current_resource
end
def state
current_resource ? current_resource.state : {}
end
def ulimits_to_hash
return nil if new_resource.ulimits.nil?
new_resource.ulimits.map do |u|
name = u.split('=')[0]
soft = u.split('=')[1].split(':')[0]
hard = u.split('=')[1].split(':')[1]
{ 'Name' => name, 'Soft' => soft.to_i, 'Hard' => hard.to_i }
end
end
end
end
end