Chef:Powerful Infrastructure Automation
上QQ阅读APP看书,第一时间看更新

Managing users

Basic user management in Chef is achieved through the use of the user resource. This resource allows you to add, remove, or otherwise manipulate users on your hosts. However, you can't possibly write recipes that contain one resource per user; it simply wouldn't scale. In order to make large-scale user management easier, we can combine some of Chef's capabilities such as data bags, per-role, per-node, and per-environment configuration to enable scalable user management.

Let's take a look at a user cookbook that can provide these abilities.

Evolution of a shell user recipe

First, let's take a look at a very naive user management recipe. This cookbook has a hardcoded users list; initially, it contains frodo and samwise and simply iterates through the list, creating users as it goes. Here is what the list may look like:

users = [
 { 
 'id' => 'frodo', 
 'uid' => '100', 
 'gid' => 100, 
 'shell' => '/bin/hobbitshell', 
 'comment' => 'Frodo of the nine fingers'
 }, {
 'id' => 'samwise', 
 'uid' => '101', 
 'gid' => 101, 
 'shell' => '/bin/gardenshell', 
 'comment' => 'Samwise the strong'
 }
]

users.each do |u|

 home_dir = "/home/#{u['id']}"

 user u['id'] do
 uid u['uid']
 gid u['gid']
 shell u['shell']
 comment u['comment']
 supports :manage_home => true
 home home_dir
 end

end

This approach will work for a handful of users, but it has the problem of being very limited in scope and difficult to maintain. It also isolates the list of users to this recipe, making it difficult to access data from other recipes and very brittle. The first thing we can improve is make the users accessible to this and any other recipe through the use of data bags. Let's take a look at how we can use data bags to make user data management simpler and more flexible.

Storing data in data bags

Data bags are designed to store arbitrary configuration data that pertains to your entire infrastructure. This may include users, global settings, firewall rules, and so on; if it can be modeled using basic JSON data structures such as arrays and dictionaries, it can be included in a data bag. We haven't touched much on these yet, so now is a good time to take a look at what they can do while modeling users.

Creating a data bag for users

Data bags are collections of data that are related to one another; for example, users, firewall rules, database servers, and so on. Here we will create a data bag that contains our user data. This is not intended to be a replacement for a directory service such as LDAP, though you could potentially use it to store all your user data and then write recipes to populate an LDAP server with user data (in this way, you may be able to keep an Active Directory system and a separate LDAP system in sync by making your Chef data bag the authoritative source for user data). Let's take a look at how to create and manipulate a data bag with user information:

[jewart]% knife data bag create users
Created data_bag[users]

Now, create a new user, frodo (you will need to have the EDITOR variable set to a text editor such as vim on Linux systems):

[jewart]% export EDITOR=vim
[jewart]% knife data bag create users frodo
Data bag users already exists

You will be presented with a new entity template that contains only one key, id, which is set to the name of the entity you created; in our case, frodo:

1 {
2 "id": "frodo",
3 }

Save this file and you will now have one, mostly empty, entity in your users data bag named frodo. You can check this with the show subcommand:

[jewart]% knife data bag show users
frodo

Every item in a data bag has to have a unique identifier, which can be meaningful or just a random identifier; in our case, it will double up as the login name for the user. We can take our previous data from the recipe and convert that to data bag elements by writing them to JSON files and uploading them with knife. To take advantage of uploads, we can create a directory, users, and create one JSON file per entry:

{ 
 "id" : "frodo", 
 "uid" : "100", 
 "gid" : 100, 
 "shell" : "/bin/hobbitshell", 
 "comment" : "Frodo of the nine fingers"
}

{
 "id" : "samwise", 
 "uid" : "101", 
 "gid" : 101, 
 "shell" : "/bin/gardenshell", 
 "comment" : "Samwise the strong"
}

Once you have created these, you should have two files, frodo.json and samwise.json inside a users directory. In order to bulk upload them, we use a knife data bag from the <dir> <data bag name> file in the following manner:

[jewart]% knife data bag from file users users 
Updated data_bag_item[users::frodo]
Updated data_bag_item[users::samwise]

You can verify whether the entries were created correctly with the knife data bag show <databag> <entity_id> command:

[jewart]% knife data bag show users frodo
comment: Frodo of the nine fingers
gid: 100
id: frodo
shell: /bin/hobbitshell
uid: 100

Searching for data

Now that we have our data in a data bag in Chef, we can search for it using the search criteria. For example, if we wanted only all users whose names start with the letter s, we can search with the following command:

[jewart]% knife search users 'id:s*' 
1 items found

chef_type: data_bag_item
comment: Samwise the strong
data_bag: users
gid: 101
id: samwise
shell: /bin/gardenshell
uid: 101

Alternatively, if we wanted all the users in a given data bag, we can perform the following search:

[jewart]% knife search users 'id:*' 
2 items found

chef_type: data_bag_item
comment: Frodo of the nine fingers
data_bag: users
gid: 100
id: frodo
shell: /bin/hobbitshell
uid: 100

chef_type: data_bag_item
comment: Samwise the strong
data_bag: users
gid: 101
id: samwise
shell: /bin/gardenshell
uid: 101

Searching inside recipes

Now that we have some data bag data created and can perform basic searches, let's see how we can use that to enhance our recipe using the built-in search method. This allows us to perform the searches we just ran with knife inside our recipes. The search method has a similar format to the knife command:

search(search_scope, search_criteria)

The following are some simple examples:

all_users = search(:users, 'id:*')
users_s = search(:users, 'id:s*')
all_nodes = search(:node, '*')

With this, we can enhance our shell user recipe to use the entities in the users data bag rather than hard code them. Our new recipe would look like the following:

# Replace the hard-coded users array with a search:
users = search(:users, 'id:*')  

# Same as before, we've just moved our data source
users.each do |u|

  home_dir = "/home/#{u['id']}"

  user u['id'] do
    uid u['uid']
    gid u['gid']
    shell u['shell']
    comment u['comment']
    supports :manage_home => true
    home home_dir
  end

end

This is just a simple search; this will work for a small-scale infrastructure with a fixed set of users, where there's no need to restrict certain groups of users to certain hosts. You can easily imagine, however, a situation where some users are provisioned only to certain hosts through groups. Let's look at how we can achieve this with some better user metadata and a more advanced search.

Enhancing your user cookbook

In our previous example, we used the search method to find all of the users in our user's data bag. Here we will go one step further to isolate users based on arbitrary groups and see how we can limit the list of users to be provisioned using a combination of search, user metadata, and node configuration.

First, we need to add a groups key to our users. Let's add that to our existing user JSON data files and add a few more users, legolas and gimli:

{
  "id" : "frodo",
  "uid" : 100,
  "gid" : 100,
  "shell" : "/bin/hobbitshell",
  "comment" : "Frodo of the nine fingers",
  "groups" : ["hobbits", "fellowship"]
}

{
  "id" : "gimli",
  "uid" : 201,
  "gid" : 201,
  "shell" : "/bin/csh",
  "comment" : "Grumpy old dwarf",
  "groups" : [ "dwarves", "fellowship" ]
}

{
  "id" : "legolas",
  "uid" : 200,
  "gid" : 200,
  "shell" : "/bin/zsh",
  "comment" : "Keen eyed Legolas",
  "groups" : [ "elves", "fellowship" ]
}

{
  "id" : "samwise",
  "uid" : "101",
  "gid" : 101,
  "shell" : "/bin/gardenshell",
  "comment" : "Samwise the strong",
  "groups" : ["hobbits", "fellowship"]
}

Once again, we update the existing records and create our new records using knife data bag from file:

[jewart]% knife data bag from file users users
Updated data_bag_item[users::frodo]
Updated data_bag_item[users::gimli]
Updated data_bag_item[users::legolas]
Updated data_bag_item[users::samwise]

Now that you have a few additional users in your data bag, and each user has some group metadata attached to it, let's take a look at how we can use this to provision only certain users on specific hosts. First, we need to be able to limit our search scope dynamically; otherwise, we will need to modify our recipe on a per-host basis and that just won't scale. We need to add a dynamic search query to our recipe with something like the following code:

search_criteria = "groups:#{node[:shell_users][:group]}"

This creates a search criteria string that will match objects that have the value specified somewhere in their groups key. In order to make this dynamic per host, we will store this value in a shell_users hash under the group key. For example, if you wanted to add all users that are in the hobbits group to a specific node, then your node's configuration would need to contain the following:

{
  "shell_users": {
    "group": "hobbits",
  }
}

This will build a search criteria of "groups" : "hobbits", which if we pass to the search method will yield all entries in the users data bag that have "hobbits" inside their groups list. Consider the following recipe code:

users = search(:users, search_criteria)

The node configuration data will expand the search criteria during an execution on this node to be the following:

search_criteria = "groups:hobbits"

Given the data we have stored in our users data bag, this would match samwise and frodo as they have the hobbits group in their groups list. We can verify this by trying the same search on the command line with knife:

[jewart]% knife search users "groups:hobbits"
2 items found

chef_type: data_bag_item
comment: Frodo of the nine fingers
data_bag: users
gid: 100
groups:
 hobbits
 fellowship
id: frodo
shell: /bin/hobbitshell
uid: 100

chef_type: data_bag_item
comment: Samwise the strong
data_bag: users
gid: 101
groups:
 hobbits
 fellowship
id: samwise
shell: /bin/gardenshell
uid: 101

As you can see, this allows us to narrowly define the list of users to be managed through the combination of entity metadata and dynamic search criteria. You can build more advanced applications using this methodology with more advanced search criteria and incorporating more of the entities' metadata.

Distributing SSH keys

In addition to managing user accounts, we can also use Chef to manage SSH keys. Because a given user's accepted SSH keys are stored in a per-user configuration file, it is quite simple to manipulate them. By creating a template for SSH-authorized keys, we can build a recipe that will take the SSH key data from the data bag and populate the authorized keys file on the host. By doing this, users' SSH keys can be stored in Chef and distributed to any number of hosts with just one command. This solves the problems typically associated with distribution and revocation of SSH keys inside an organization.

Templating the authorized keys

Here is a sample template we will use for our user's authorized keys file; this would be defined in an authorized_keys.erb file:

<% if @ssh_keys.is_a?(Array) %>
<%= @ssh_keys.join("\n") %>
<% else %>
<%= @ssh_keys %>
<% end %>

This is a very simple template that has only two cases: if the template variable ssh_keys is an array, it will print them out with a new line in between them; otherwise, it will simply print out the contents of the variable.

To use this template, we will simply provide it with a list of SSH-compatible key strings:

template "#{home_dir}/.ssh/authorized_keys" do
  source "authorized_keys.erb"
  owner u['id']
  group u['gid'] || u['id']
  mode "0600"
  variables :ssh_keys => u['ssh_keys']
end

Now, we can modify one of our previous user JSON entities to add SSH keys:

{
  "id" : "frodo",
  "uid" : 100,
  "gid" : 100,
  "shell" : "/bin/hobbitshell",
  "comment" : "Frodo of the nine fingers",
  "groups" : ["hobbits", "fellowship"],
  "ssh_keys": [
    "ssh-dss RG9uJ3Qgd29ycnksIFNhbS4gUm9zaWUga25vd3MgYW4gaWRpb3Qgd2hlbiBzaGUgc2VlcyBvbmUu frodo@shire",
    "ssh-dss TXkgbWFzdGVyLCBTYXVyb24gdGhlIEdyZWF0LCBiaWRzIHRoZWUgd2VsY29tZS4gSXMgdGhlcmUgYW55IGluIHRoaXMgcm91dCB3aXRoIGF1dGhvcml0eSB0byB0cmVhdCB3aXRoIG1lPyA= sauron@mordor"
  ]
}
% knife data bag from file users ssh_keys/frodo.json
Updated data_bag_item[users::frodo]

Once your user has been updated, check whether your newly added metadata has been updated, looking for your new ssh_keys key in the entity. In order to do that, you can show the contents of your data bag using the following command:

% knife data bag show users frodo 

The output of this should line up with your newly updated JSON content. With these added, we can write a new recipe that will allow us to deploy authorized_keys files for each user on our hosts. Our recipe will use the same search criteria from our previous recipe as we want to apply our SSH keys to all of our shell users.

This recipe is responsible for making sure that the proper directory for SSH is created and has the correct permissions, as well as creating the authorized_keys file with the necessary permissions and storing the SSH keys associated with the user in /home/user/.ssh/authorized_keys:

search_criteria = "groups:#{node[:shell_users][:group]}"

search(:users, search_criteria) do |u|

  home_dir = "/home/#{u['id']}"

  directory "#{home_dir}/.ssh" do
    owner u['id']
    group u['gid'] 
    mode "0700"
    recursive true
  end

  template "#{home_dir}/.ssh/authorized_keys" do
    source "authorized_keys.erb"
    owner u['id']
    group u['gid']
    mode "0600"
    variables :ssh_keys => u['ssh_keys']
  end

end

Adding deployment keys

If you have ever deployed a Rails application to hosts that need to have access to your source code in a GitHub or BitBucket repository, then you will know how handy it is to manage deployment keys across a fleet of hosts. We can easily generate a recipe that looks at a node's list of deployment users following our previous examples as a starting point. Here, we look for deploy users instead of shell users, as these are the ones we want to manage deployment keys for. Note that in this example, these users would also need to be included in the shell_users group to ensure that they get created by our previous recipe:

search_criteria = "groups:#{node[:deploy_users][:group]}"

search(:users, search_criteria) do |u|
  home_dir = "/home/#{u['id']}"

  directory "#{home_dir}/.ssh" do
    owner u['id']
    group u['gid'] || u['id']
    mode "0700"
    recursive true
  end

  template "#{home_dir}/.ssh/id_rsa" do 
    source "deploy_key.erb"
    owner u['id']
    group u['gid'] || u['id']
    mode "0600"
    variables :key => u['deploy_key']
  end
end

To use this new recipe, the deployment users would need to be modified to include a group identifier and their private key. The group would be reserved for users involved in deploying your application and be added to the user's groups key in Chef. Additionally, an unencrypted SSH private key would need to be present in a deploy_key field.

Tip

Including unencrypted SSH keys can pose a security risk. This can be mitigated using encrypted data bags or an external security material management service.