Merge pull request #5339 from YpNo/master
Chef Provisioner : Support real chef-zero/local mode
This commit is contained in:
commit
9bdff90d7c
|
@ -29,6 +29,7 @@ module VagrantPlugins
|
|||
attr_accessor :file_cache_path
|
||||
attr_accessor :file_backup_path
|
||||
attr_accessor :verbose_logging
|
||||
attr_accessor :enable_reporting
|
||||
|
||||
def initialize
|
||||
super
|
||||
|
@ -53,6 +54,7 @@ module VagrantPlugins
|
|||
@file_cache_path = UNSET_VALUE
|
||||
@file_backup_path = UNSET_VALUE
|
||||
@verbose_logging = UNSET_VALUE
|
||||
@enable_reporting = UNSET_VALUE
|
||||
|
||||
# Runner options
|
||||
@json = {}
|
||||
|
@ -85,6 +87,7 @@ module VagrantPlugins
|
|||
@file_backup_path = "/var/chef/backup" if @file_backup_path == UNSET_VALUE
|
||||
@file_cache_path = "/var/chef/cache" if @file_cache_path == UNSET_VALUE
|
||||
@verbose_logging = false if @verbose_logging == UNSET_VALUE
|
||||
@enable_reporting = true if @enable_reporting == UNSET_VALUE
|
||||
|
||||
if @encrypted_data_bag_secret_key_path == UNSET_VALUE
|
||||
@encrypted_data_bag_secret_key_path = nil
|
||||
|
|
|
@ -3,26 +3,104 @@ require_relative "chef_solo"
|
|||
module VagrantPlugins
|
||||
module Chef
|
||||
module Config
|
||||
class ChefZero < ChefSolo
|
||||
attr_accessor :nodes_path
|
||||
class ChefZero < BaseRunner
|
||||
# The path on disk where Chef cookbooks are stored.
|
||||
# Default is "cookbooks".
|
||||
# @return [String]
|
||||
attr_accessor :cookbooks_path
|
||||
|
||||
# The path where data bags are stored on disk.
|
||||
# @return [String]
|
||||
attr_accessor :data_bags_path
|
||||
|
||||
# The path where environments are stored on disk.
|
||||
# @return [String]
|
||||
attr_accessor :environments_path
|
||||
|
||||
# The path where roles are stored on disk.
|
||||
# @return [String]
|
||||
attr_accessor :roles_path
|
||||
|
||||
# The type of synced folders to use.
|
||||
# @return [String]
|
||||
attr_accessor :synced_folder_type
|
||||
|
||||
def initialize
|
||||
super
|
||||
|
||||
@nodes_path = UNSET_VALUE
|
||||
@cookbooks_path = UNSET_VALUE
|
||||
@data_bags_path = UNSET_VALUE
|
||||
@environments_path = UNSET_VALUE
|
||||
@roles_path = UNSET_VALUE
|
||||
@synced_folder_type = UNSET_VALUE
|
||||
end
|
||||
|
||||
def finalize!
|
||||
super
|
||||
|
||||
@nodes_path = [] if @nodes_path == UNSET_VALUE
|
||||
@synced_folder_type = nil if @synced_folder_type == UNSET_VALUE
|
||||
|
||||
if @cookbooks_path == UNSET_VALUE
|
||||
@cookbooks_path = []
|
||||
@cookbooks_path << [:host, "cookbooks"] if !@recipe_url
|
||||
@cookbooks_path << [:vm, "cookbooks"]
|
||||
end
|
||||
|
||||
@data_bags_path = [] if @data_bags_path == UNSET_VALUE
|
||||
@roles_path = [] if @roles_path == UNSET_VALUE
|
||||
@environments_path = [] if @environments_path == UNSET_VALUE
|
||||
@environments_path = [@environments_path].flatten
|
||||
|
||||
# Make sure the path is an array.
|
||||
@nodes_path = prepare_folders_config(@nodes_path)
|
||||
@cookbooks_path = prepare_folders_config(@cookbooks_path)
|
||||
@data_bags_path = prepare_folders_config(@data_bags_path)
|
||||
@roles_path = prepare_folders_config(@roles_path)
|
||||
@environments_path = prepare_folders_config(@environments_path)
|
||||
|
||||
end
|
||||
|
||||
def validate(machine)
|
||||
{ "chef zero provisioner" => super["chef solo provisioner"] }
|
||||
errors = validate_base(machine)
|
||||
|
||||
if [cookbooks_path].flatten.compact.empty?
|
||||
errors << I18n.t("vagrant.config.chef.cookbooks_path_empty")
|
||||
end
|
||||
|
||||
if environment && environments_path.empty?
|
||||
errors << I18n.t("vagrant.config.chef.environment_path_required")
|
||||
end
|
||||
|
||||
environments_path.each do |type, raw_path|
|
||||
next if type != :host
|
||||
|
||||
path = Pathname.new(raw_path).expand_path(machine.env.root_path)
|
||||
if !path.directory?
|
||||
errors << I18n.t("vagrant.config.chef.environment_path_missing",
|
||||
path: raw_path.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
{ "chef zero provisioner" => errors }
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# This takes any of the configurations that take a path or
|
||||
# array of paths and turns it into the proper format.
|
||||
#
|
||||
# @return [Array]
|
||||
def prepare_folders_config(config)
|
||||
# Make sure the path is an array
|
||||
config = [config] if !config.is_a?(Array) || config.first.is_a?(Symbol)
|
||||
|
||||
return [] if config.flatten.compact.empty?
|
||||
|
||||
# Make sure all the paths are in the proper format
|
||||
config.map do |path|
|
||||
path = [:host, path] if !path.is_a?(Array)
|
||||
path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -93,6 +93,7 @@ module VagrantPlugins
|
|||
log_level: @config.log_level.to_sym,
|
||||
node_name: @config.node_name,
|
||||
verbose_logging: @config.verbose_logging,
|
||||
enable_reporting: @config.enable_reporting,
|
||||
http_proxy: @config.http_proxy,
|
||||
http_proxy_user: @config.http_proxy_user,
|
||||
http_proxy_pass: @config.http_proxy_pass,
|
||||
|
|
|
@ -1,36 +1,207 @@
|
|||
require "digest/md5"
|
||||
require "securerandom"
|
||||
require "set"
|
||||
|
||||
require "log4r"
|
||||
|
||||
require_relative "chef_solo"
|
||||
require "vagrant/util/counter"
|
||||
|
||||
require_relative "base"
|
||||
|
||||
module VagrantPlugins
|
||||
module Chef
|
||||
module Provisioner
|
||||
# This class implements provisioning via chef-zero.
|
||||
class ChefZero < ChefSolo
|
||||
attr_reader :node_folders
|
||||
class ChefZero < Base
|
||||
extend Vagrant::Util::Counter
|
||||
include Vagrant::Util::Counter
|
||||
include Vagrant::Action::Builtin::MixinSyncedFolders
|
||||
|
||||
attr_reader :environments_folders
|
||||
attr_reader :cookbook_folders
|
||||
attr_reader :role_folders
|
||||
attr_reader :data_bags_folders
|
||||
|
||||
def initialize(machine, config)
|
||||
super
|
||||
@logger = Log4r::Logger.new("vagrant::provisioners::chef_zero")
|
||||
@shared_folders = []
|
||||
end
|
||||
|
||||
def configure(root_config)
|
||||
super
|
||||
@cookbook_folders = expanded_folders(@config.cookbooks_path, "cookbooks")
|
||||
@role_folders = expanded_folders(@config.roles_path, "roles")
|
||||
@data_bags_folders = expanded_folders(@config.data_bags_path, "data_bags")
|
||||
@environments_folders = expanded_folders(@config.environments_path, "environments")
|
||||
|
||||
@node_folders = expanded_folders(@config.nodes_path, "nodes")
|
||||
|
||||
share_folders(root_config, "csn", @node_folders)
|
||||
existing = synced_folders(@machine, cached: true)
|
||||
share_folders(root_config, "csc", @cookbook_folders, existing)
|
||||
share_folders(root_config, "csr", @role_folders, existing)
|
||||
share_folders(root_config, "csdb", @data_bags_folders, existing)
|
||||
share_folders(root_config, "cse", @environments_folders, existing)
|
||||
end
|
||||
|
||||
def provision
|
||||
super(:zero)
|
||||
def provision(mode = :client)
|
||||
install_chef
|
||||
# Verify that the proper shared folders exist.
|
||||
check = []
|
||||
@shared_folders.each do |type, local_path, remote_path|
|
||||
# We only care about checking folders that have a local path, meaning
|
||||
# they were shared from the local machine, rather than assumed to
|
||||
# exist on the VM.
|
||||
check << remote_path if local_path
|
||||
end
|
||||
|
||||
chown_provisioning_folder
|
||||
verify_shared_folders(check)
|
||||
verify_binary(chef_binary_path("chef-client"))
|
||||
upload_encrypted_data_bag_secret
|
||||
setup_json
|
||||
setup_zero_config
|
||||
run_chef(mode)
|
||||
delete_encrypted_data_bag_secret
|
||||
end
|
||||
|
||||
def solo_config
|
||||
super.merge(
|
||||
# Converts paths to a list of properly expanded paths with types.
|
||||
def expanded_folders(paths, appended_folder=nil)
|
||||
# Convert the path to an array if it is a string or just a single
|
||||
# path element which contains the folder location (:host or :vm)
|
||||
paths = [paths] if paths.is_a?(String) || paths.first.is_a?(Symbol)
|
||||
|
||||
results = []
|
||||
paths.each do |type, path|
|
||||
# Create the local/remote path based on whether this is a host
|
||||
# or VM path.
|
||||
local_path = nil
|
||||
remote_path = nil
|
||||
if type == :host
|
||||
# Get the expanded path that the host path points to
|
||||
local_path = File.expand_path(path, @machine.env.root_path)
|
||||
|
||||
if File.exist?(local_path)
|
||||
# Path exists on the host, setup the remote path. We use
|
||||
# the MD5 of the local path so that it is predictable.
|
||||
key = Digest::MD5.hexdigest(local_path)
|
||||
remote_path = "#{@config.provisioning_path}/#{key}"
|
||||
else
|
||||
@machine.ui.warn(I18n.t("vagrant.provisioners.chef.cookbook_folder_not_found_warning",
|
||||
path: local_path.to_s))
|
||||
next
|
||||
end
|
||||
else
|
||||
# Path already exists on the virtual machine. Expand it
|
||||
# relative to where we're provisioning.
|
||||
remote_path = File.expand_path(path, @config.provisioning_path)
|
||||
|
||||
# Remove drive letter if running on a windows host. This is a bit
|
||||
# of a hack but is the most portable way I can think of at the moment
|
||||
# to achieve this. Otherwise, Vagrant attempts to share at some crazy
|
||||
# path like /home/vagrant/c:/foo/bar
|
||||
remote_path = remote_path.gsub(/^[a-zA-Z]:/, "")
|
||||
end
|
||||
|
||||
# If we have specified a folder name to append then append it
|
||||
remote_path += "/#{appended_folder}" if appended_folder
|
||||
|
||||
# Append the result
|
||||
results << [type, local_path, remote_path]
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
# Shares the given folders with the given prefix. The folders should
|
||||
# be of the structure resulting from the `expanded_folders` function.
|
||||
def share_folders(root_config, prefix, folders, existing=nil)
|
||||
existing_set = Set.new
|
||||
(existing || []).each do |_, fs|
|
||||
fs.each do |id, data|
|
||||
existing_set.add(data[:guestpath])
|
||||
end
|
||||
end
|
||||
|
||||
folders.each do |type, local_path, remote_path|
|
||||
next if type != :host
|
||||
|
||||
# If this folder already exists, then we don't share it, it means
|
||||
# it was already put down on disk.
|
||||
if existing_set.include?(remote_path)
|
||||
@logger.debug("Not sharing #{local_path}, exists as #{remote_path}")
|
||||
next
|
||||
end
|
||||
|
||||
opts = {}
|
||||
opts[:id] = "v-#{prefix}-#{self.class.get_and_update_counter(:shared_folder)}"
|
||||
opts[:type] = @config.synced_folder_type if @config.synced_folder_type
|
||||
|
||||
root_config.vm.synced_folder(local_path, remote_path, opts)
|
||||
end
|
||||
|
||||
@shared_folders += folders
|
||||
end
|
||||
|
||||
def setup_zero_config
|
||||
setup_config("provisioners/chef_zero/zero", "client.rb", {
|
||||
local_mode: true,
|
||||
node_path: guest_paths(@node_folders).first
|
||||
)
|
||||
enable_reporting: false,
|
||||
cookbooks_path: guest_paths(@cookbook_folders),
|
||||
roles_path: guest_paths(@role_folders),
|
||||
data_bags_path: guest_paths(@data_bags_folders).first,
|
||||
environments_path: guest_paths(@environments_folders).first,
|
||||
})
|
||||
end
|
||||
|
||||
def run_chef(mode)
|
||||
if @config.run_list && @config.run_list.empty?
|
||||
@machine.ui.warn(I18n.t("vagrant.chef_run_list_empty"))
|
||||
end
|
||||
|
||||
if @machine.guest.capability?(:wait_for_reboot)
|
||||
@machine.guest.capability(:wait_for_reboot)
|
||||
end
|
||||
|
||||
command = build_command(:client)
|
||||
|
||||
@config.attempts.times do |attempt|
|
||||
if attempt == 0
|
||||
@machine.ui.info I18n.t("vagrant.provisioners.chef.running_#{mode}")
|
||||
else
|
||||
@machine.ui.info I18n.t("vagrant.provisioners.chef.running_#{mode}_again")
|
||||
end
|
||||
|
||||
opts = { error_check: false, elevated: true }
|
||||
exit_status = @machine.communicate.sudo(command, opts) do |type, data|
|
||||
# Output the data with the proper color based on the stream.
|
||||
color = type == :stdout ? :green : :red
|
||||
|
||||
data = data.chomp
|
||||
next if data.empty?
|
||||
|
||||
@machine.ui.info(data, color: color)
|
||||
end
|
||||
|
||||
# There is no need to run Chef again if it converges
|
||||
return if exit_status == 0
|
||||
end
|
||||
|
||||
# If we reached this point then Chef never converged! Error.
|
||||
raise ChefError, :no_convergence
|
||||
end
|
||||
|
||||
def verify_shared_folders(folders)
|
||||
folders.each do |folder|
|
||||
@logger.debug("Checking for shared folder: #{folder}")
|
||||
if !@machine.communicate.test("test -d #{folder}", sudo: true)
|
||||
raise ChefError, :missing_shared_folders
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Extracts only the remote paths from a list of folders
|
||||
def guest_paths(folders)
|
||||
folders.map { |parts| parts[2] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
<% if node_name %>
|
||||
node_name "<%= node_name %>"
|
||||
<% end %>
|
||||
file_cache_path "<%= file_cache_path %>"
|
||||
file_backup_path "<%= file_backup_path %>"
|
||||
cookbook_path <%= cookbooks_path.inspect %>
|
||||
<% if roles_path %>
|
||||
role_path <%= roles_path.size == 1 ? roles_path.first.inspect : roles_path.inspect %>
|
||||
<% end %>
|
||||
log_level <%= log_level.inspect %>
|
||||
verbose_logging <%= verbose_logging.inspect %>
|
||||
<% if !enable_reporting %>
|
||||
enable_reporting <%= enable_reporting.inspect %>
|
||||
<% end %>
|
||||
|
||||
encrypted_data_bag_secret <%= encrypted_data_bag_secret.inspect %>
|
||||
|
||||
<% if data_bags_path -%>
|
||||
data_bag_path <%= data_bags_path.inspect %>
|
||||
<% end %>
|
||||
|
||||
<% if environments_path %>
|
||||
environment_path <%= environments_path.inspect %>
|
||||
<% end -%>
|
||||
|
||||
<% if environment %>
|
||||
environment "<%= environment %>"
|
||||
<% end -%>
|
||||
|
||||
<% if local_mode -%>
|
||||
chef_zero.enabled true
|
||||
local_mode true
|
||||
<% end -%>
|
||||
<% if node_path -%>
|
||||
node_path <%= node_path.inspect %>
|
||||
<% end -%>
|
||||
|
||||
<% if formatter %>
|
||||
add_formatter "<%= formatter %>"
|
||||
<% end %>
|
||||
|
||||
<% if custom_configuration -%>
|
||||
Chef::Config.from_file "<%= custom_configuration %>"
|
||||
<% end -%>
|
|
@ -148,6 +148,13 @@ describe VagrantPlugins::Chef::Config::BaseRunner do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#enable_reporting" do
|
||||
it "defaults to true" do
|
||||
subject.finalize!
|
||||
expect(subject.enable_reporting).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#run_list" do
|
||||
it "defaults to an empty array" do
|
||||
subject.finalize!
|
||||
|
|
|
@ -9,11 +9,132 @@ describe VagrantPlugins::Chef::Config::ChefZero do
|
|||
|
||||
let(:machine) { double("machine") }
|
||||
|
||||
describe "#nodes_path" do
|
||||
it "defaults to an array" do
|
||||
describe "#cookbooks_path" do
|
||||
it "defaults to something" do
|
||||
subject.finalize!
|
||||
expect(subject.nodes_path).to be_a(Array)
|
||||
expect(subject.nodes_path).to be_empty
|
||||
expect(subject.cookbooks_path).to eq([
|
||||
[:host, "cookbooks"],
|
||||
[:vm, "cookbooks"],
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe "#data_bags_path" do
|
||||
it "defaults to an empty array" do
|
||||
subject.finalize!
|
||||
expect(subject.data_bags_path).to be_a(Array)
|
||||
expect(subject.data_bags_path).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe "#environments_path" do
|
||||
it "defaults to an empty array" do
|
||||
subject.finalize!
|
||||
expect(subject.environments_path).to be_a(Array)
|
||||
expect(subject.environments_path).to be_empty
|
||||
end
|
||||
|
||||
it "merges deeply nested paths" do
|
||||
subject.environments_path = ["/foo", "/bar", ["/zip"]]
|
||||
subject.finalize!
|
||||
expect(subject.environments_path)
|
||||
.to eq([:host, :host, :host].zip %w(/foo /bar /zip))
|
||||
end
|
||||
end
|
||||
|
||||
describe "#roles_path" do
|
||||
it "defaults to an empty array" do
|
||||
subject.finalize!
|
||||
expect(subject.roles_path).to be_a(Array)
|
||||
expect(subject.roles_path).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe "#synced_folder_type" do
|
||||
it "defaults to nil" do
|
||||
subject.finalize!
|
||||
expect(subject.synced_folder_type).to be(nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#validate" do
|
||||
before do
|
||||
allow(machine).to receive(:env)
|
||||
.and_return(double("env",
|
||||
root_path: "",
|
||||
))
|
||||
|
||||
subject.cookbooks_path = ["/cookbooks", "/more/cookbooks"]
|
||||
end
|
||||
|
||||
let(:result) { subject.validate(machine) }
|
||||
let(:errors) { result["chef zero provisioner"] }
|
||||
|
||||
context "when the cookbooks_path is nil" do
|
||||
it "returns an error" do
|
||||
subject.cookbooks_path = nil
|
||||
subject.finalize!
|
||||
expect(errors).to eq [I18n.t("vagrant.config.chef.cookbooks_path_empty")]
|
||||
end
|
||||
end
|
||||
|
||||
context "when the cookbooks_path is an empty array" do
|
||||
it "returns an error" do
|
||||
subject.cookbooks_path = []
|
||||
subject.finalize!
|
||||
expect(errors).to eq [I18n.t("vagrant.config.chef.cookbooks_path_empty")]
|
||||
end
|
||||
end
|
||||
|
||||
context "when the cookbooks_path is an array with nil" do
|
||||
it "returns an error" do
|
||||
subject.cookbooks_path = [nil, nil]
|
||||
subject.finalize!
|
||||
expect(errors).to eq [I18n.t("vagrant.config.chef.cookbooks_path_empty")]
|
||||
end
|
||||
end
|
||||
|
||||
context "when environments is given" do
|
||||
before do
|
||||
subject.environment = "production"
|
||||
end
|
||||
|
||||
context "when the environments_path is nil" do
|
||||
it "returns an error" do
|
||||
subject.environments_path = nil
|
||||
subject.finalize!
|
||||
expect(errors).to eq [I18n.t("vagrant.config.chef.environment_path_required")]
|
||||
end
|
||||
end
|
||||
|
||||
context "when the environments_path is an empty array" do
|
||||
it "returns an error" do
|
||||
subject.environments_path = []
|
||||
subject.finalize!
|
||||
expect(errors).to eq [I18n.t("vagrant.config.chef.environment_path_required")]
|
||||
end
|
||||
end
|
||||
|
||||
context "when the environments_path is an array with nil" do
|
||||
it "returns an error" do
|
||||
subject.environments_path = [nil, nil]
|
||||
subject.finalize!
|
||||
expect(errors).to eq [I18n.t("vagrant.config.chef.environment_path_required")]
|
||||
end
|
||||
end
|
||||
|
||||
context "when the environments_path does not exist" do
|
||||
it "returns an error" do
|
||||
env_path = "/path/to/environments/that/will/never/exist"
|
||||
subject.environments_path = env_path
|
||||
subject.finalize!
|
||||
expect(errors).to eq [
|
||||
I18n.t("vagrant.config.chef.environment_path_missing",
|
||||
path: env_path
|
||||
)
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -105,3 +105,6 @@ which include [Chef Solo](/v2/provisioning/chef_solo.html), [Chef Zero](/v2/prov
|
|||
|
||||
* `verbose_logging` (boolean) - Whether or not to enable the Chef
|
||||
`verbose_logging` option. By default this is false.
|
||||
|
||||
* `enable_reporting` (boolean) - Whether or not to enable the Chef
|
||||
`enable_reporting` option. By default this is true.
|
||||
|
|
|
@ -31,8 +31,29 @@ This section lists the complete set of available options for the Chef Zero
|
|||
provisioner. More detailed examples of how to use the provisioner are
|
||||
available below this section.
|
||||
|
||||
* `nodes_path` (string) - A path where the Chef nodes are stored. Be default,
|
||||
no node path is set.
|
||||
* `cookbooks_path` (string or array) - A list of paths to where cookbooks
|
||||
are stored. By default this is "cookbooks", expecting a cookbooks folder
|
||||
relative to the Vagrantfile location.
|
||||
|
||||
* `data_bags_path` (string) - A path where data bags are stored. By default, no
|
||||
data bag path is set.
|
||||
|
||||
* `environments_path` (string) - A path where environment definitions are
|
||||
located. By default, no environments folder is set.
|
||||
|
||||
* `environment` (string) - The environment you want the Chef run to be
|
||||
a part of. This requires Chef 11.6.0 or later, and that `environments_path`
|
||||
is set.
|
||||
|
||||
* `roles_path` (string or array) - A list of paths where roles are defined.
|
||||
By default this is empty. Multiple role directories are only supported by
|
||||
Chef 11.8.0 and later.
|
||||
|
||||
* `synced_folder_type` (string) - The type of synced folders to use when
|
||||
sharing the data required for the provisioner to work properly. By default
|
||||
this will use the default synced folder type. For example, you can set this
|
||||
to "nfs" to use NFS synced folders.
|
||||
|
||||
|
||||
In addition to all the options listed above, the Chef Zero provisioner supports
|
||||
the [common options for all Chef provisioners](/v2/provisioning/chef_common.html).
|
||||
|
@ -50,8 +71,8 @@ Vagrant.configure("2") do |config|
|
|||
config.vm.provision "chef_zero" do |chef|
|
||||
# Specify the local paths where Chef data is stored
|
||||
chef.cookbooks_path = "cookbooks"
|
||||
chef.data_bags_path = "data_bags"
|
||||
chef.roles_path = "roles"
|
||||
chef.nodes_path = "nodes"
|
||||
|
||||
# Add a recipe
|
||||
chef.add_recipe "apache"
|
||||
|
|
Loading…
Reference in New Issue