One key DevOps concept is the idea of infrastructure as code (IaC). Instead of managing your infrastructure manually, you define and build your systems by running your automation code. Red Hat Ansible Automation Platform is a key tool that can help you implement this approach.
If Ansible projects are the code used to define the infrastructure, then a version control system (VCS) such as Git should be used to track and control changes to the code.
Version control also enables you to implement a lifecycle for the different stages of your infrastructure code, such as development, QA, and production. You can commit your changes to a branch and test those changes in noncritical development and QA environments. When you are confident with the changes, you can merge them to the main production code and apply the changes to your production infrastructure.
Git is a distributed version control system (DVCS) that enables you to manage changes to files in a project collaboratively. Using Git provides many benefits:
You can review and restore earlier versions of files.
You can compare two versions of the same file to identify changes.
You can record a log of who made what changes, and when those changes were made.
Multiple users can collaboratively modify files, resolve conflicting changes, and merge the changes.
Using Git, you can start by cloning an existing shared project from a remote repository. Cloning a project creates a complete copy of the original remote repository as a local repository. This local copy has the entire history of the files in Git, and not just the latest snapshot of the project files.
You make edits in a working tree, which is a checkout of a single snapshot of the project. Next, you place the modified files in a staging area, ready to commit the differences to the local repository. At this point, no changes have been made to the shared remote repository.
When you are ready to share the completed work, you push the changes to the remote repository. Likewise, when you are ready to update the local repository with the latest changes to the remote repository, you pull the changes, which fetches them from the remote repository and merges them into the local repository.
To use Git effectively, you must be aware of the three possible states of a file in the working tree: modified, staged, or committed.
| State | Description |
|---|---|
| Modified | You edited the copy of the file in the working tree and it is different from the latest version in the repository. |
| Staged | You added the modified file to a list of changed files to commit as a set, but the staged files have not been committed yet. |
| Committed | You committed the modified file to the local repository. |
When you commit the file to the local repository, that file can be pushed to or pulled by a remote repository.
The presentation in this section assumes that you are using Git from the command line of a Bash shell. A number of programming editors and IDEs have integrated Git support, and although the configuration and UI details might differ, they provide a different front end to the same workflow.
Because Git users frequently modify projects with multiple contributors, Git records the user's name and email address on each of their commits.
These values can be defined at a project level, but you can also set global defaults for a user.
The git config command controls these settings.
Using git config with the --global option manages the default settings for all Git projects to which you contribute by saving the settings in your ~/.gitconfig file.
[user@host ~]$git config --global user.name 'Peter Shadowman'[user@host ~]$git config --global user.email peter@host.example.com
If Bash is your shell, then another useful, optional setting, is to configure your prompt to automatically update to indicate the status of your working tree.
The easiest way to do it is to use the git-prompt.sh script that ships with the git package.
To modify your shell prompt in this way, add the following lines to your ~/.bashrc file.
source /usr/share/git-core/contrib/completion/git-prompt.sh export GIT_PS1_SHOWDIRTYSTATE=true export GIT_PS1_SHOWUNTRACKEDFILES=true export PS1='[\u@\h \W$(declare -F __git_ps1 &>/dev/null && __git_ps1 " (%s)")]\$ '
If your current directory is in a Git working tree, then the name of the current Git branch for the working tree is displayed in parentheses. If you have untracked, modified, or staged files in your working tree, then the prompt could indicate one of the following meanings:
(branch *) means that you have modified a tracked file.
(branch +) means that you have modified a tracked file and used git add to stage it.
(branch %) means that you have untracked files in your tree.
Combinations of markers are possible, such as (branch *+).
When working on shared projects, you clone an existing upstream repository with the git clone command.
The provided path name or URL determines which repository is cloned into the current directory.
This process creates a working tree of files ready for revisions.
Because the working tree is unmodified, it is initially in a clean state.
A clean state means the working tree does not contain any modified, staged, or new files.
For example, the following command clones the project.git repository at git.lab.example.com by connecting using the SSH protocol and authenticating as the git user:
[user@host ~]$ git clone git@git.lab.example.com:project.gitAnother way to start the Git workflow is to create a new, private project with the git init command.
When a project is started in this way, no remote repository is created.
Use git init --bare to create a bare repository on a server.
Bare repositories do not have a local working tree.
Therefore, you cannot use the bare repository to apply file changes.
Git servers usually contain bare repositories, because the working tree is not needed on the server. When you create a local copy of the repository, you usually need a working tree to make local changes, so you create a non-bare clone. The server must also be set up to allow users to clone, pull from, and push to the repository using either the HTTPS or SSH protocol.
As you work, you create new files and modify existing files in the working tree.
This changes the working tree to a dirty state.
The git status command displays detailed information about which files in the working tree are modified but unstaged, untracked (new), or staged for the next commit.
The git add command stages files, preparing them to be committed.
Only files that are staged to the staging area are saved to the repository on the next commit.
If you are working on two changes at the same time, then you can organize the files into two commits for better tracking of changes.
The git rm command removes a file from the working directory and also stages its removal from the repository on the next commit.
The git reset command removes a file from the staging area that has been added for the next commit.
This command has no effect on the file's contents in the working tree.
The git commit command commits the staged files to the local Git repository.
You must provide a log message that explains why you are saving the current set of staged files.
Failure to provide a message aborts the commit.
Log messages do not have to be long, but they should be meaningful so that they are useful.
The git commit command by itself does not automatically commit changed files in the working tree.
The git commit -a command stages and commits all the modified files in one step.
However, that command does not include any untracked (newly created) files in the directory.
When you add a new file, you must explicitly run the git add command to stage the new file.
Meaningful and concise commit messages are the key to maintaining a clear history for an Ansible project. There are many approaches to a good commit message, but most of these approaches agree on the following three points:
The first line should be a short (usually fewer than 50 character) summary of the reason for the commit.
A blank line follows the first line, and then the rest of the message must explain all the details, description of what was done, and the reasons for the commit.
If available, then add references to an issue or feature tracker. These references expand the commit message with additional text, related people, or history.
The git push command uploads changes made to the local repository to the remote repository.
One common way to coordinate work with Git is for all developers to push their work to the same, shared, remote repository.
Before Git pushes can work, you must define the default push method to use.
The following command sets the default push method to the simple method.
This is the safest option for beginners.
[user@host ~]$ git config --global push.default simpleUse the git pull command to perform the following tasks:
Fetch commits from the remote repository.
Add those commits to the local directory.
Merge changes into the files in the working tree.
Run the git pull command frequently to stay current with the changes that others are making to the project in the remote repository.
An alternative approach is to use the git fetch command to download the changes from the remote repository to your local repository.
You then use the git merge command to merge the changes in the tracking branch to your current branch.
Later in this section, you learn more about Git branches.
Part of the point of a version control system is to track a history of commits. A commit hash identifies each commit.
The git log command displays the commit log messages with the associated ID hashes for each commit.
The git show command shows what was in the change set for a particular commit hash.
You do not need to use the entire hash with this command; only enough of it to uniquely identify a particular commit in the repository.
These hashes can also be used to revert to earlier commits or otherwise explore the version control system's history.commit-hash
Table 1.1. Git Quick Reference
| Command | Description |
|---|---|
git clone
| Clone an existing Git project from the remote repository at URL into the current directory. |
git status
| Display the status of files in the working tree. |
git add
| Stage a new or changed file for the next commit. |
git rm
| Stage removal of a file for the next commit. |
git reset
| Unstage files that have been staged for the next commit. |
git commit
| Commit the staged files to the local repository with a descriptive message. |
git push
| Push changes in the local repository to the remote repository. |
git pull
| Fetch updates from the remote repository to the local repository and merge them into the working tree. |
git revert
| Create a new commit, undoing the changes in the referenced commit. You can use the commit hash that identifies the commit, although there are other ways to reference a commit. |
git init
| Create a new project. |
git log
| Display the commit log messages. |
git show
| Shows what was in the change set for a particular commit hash. |
This is a highly simplified introduction to Git. It has made some assumptions and avoided discussion of the important topics of branches and merging. Some of these assumptions include:
You cloned the local repository from a remote repository.
You are using only one local branch.
You configured the local branch to fetch from and push to a branch on the original remote repository.
You provided write access to the remote repository, such that git push works.
Branches enable you to make changes to your project, working on new development, without altering your main branch. Then, when you are satisfied with your changes, you can merge the changes in your branch to the main branch of development, integrating them.
A branch is essentially a pointer to a particular commit in your commit tree. Each commit contains the changes it made and information about how to reference it and what its relationship is to other commits in the commit tree. The information in a commit includes:
A unique ID in the form of a 40-character hexadecimal string. This ID is the SHA-1 hash of the contents of the commit.
A list of repository files that it changed, and the exact changes to each of them.
The ID of the parent commit that defines the status of the repository before applying the commit in question.
Information about the author and the creator (or committer) for the commit in the commit data.
A list of references. A reference is like a named pointer to the commit. The most common references are tags and branches.
The git commit command generates a new commit with all changes added to the stage with the git add command.
You can display a Git repository as a commit graph, and branches as references to commits.
The preceding figure shows a sample Git repository containing 11 commits.
The most recent commits are to the right; the arrows point to earlier commits.
It has three branches (main, feature/1, and feature/2) and a single tag (tag/1.0).
The HEAD reference is the current commit in the local working tree.
If you make a change in your working tree, stage it with the git add command, and commit it with the git commit command.
A new commit is then created with the most recent commit as its parent, and HEAD moves to point to the new commit.
Different branches in Git enable different work streams to evolve in parallel on the same Git repository. Commits for each work stream append only to that branch.
Use the git branch command to create a new branch from the current HEAD commit.
This command creates a reference for the new branch, but it does not set the current HEAD to this branch.
Use the git checkout command to move the HEAD to the appropriate branch.
In the preceding figure, the most recent commit for the main branch (and HEAD at that time) was commit 5749661, which occurred at some point in the past.
We are assuming you ran the git branch feature/1 command, creating a branch named feature/1.
Then, you ran git checkout feature/1 to indicate that future commits should be made to that branch.
Finally, you staged and committed two commits to the feature/1 branch, commits 60c6c2c and 8f7c801.
After running these commands, the HEAD was at commit 8f7c801, and the new commits were added to the feature/1 branch.
Next, you wanted to add commits to the main branch.
After moving HEAD to the latest commit on that branch, by running the git checkout main command, two new commits were made: 96e9ce7 and 44bd644.
The HEAD pointed to 44bd644, and commits were added to the main branch.
When work is complete on a branch, you can merge the branch with the original branch. This enables you to work in parallel on new features and bug fixes, leaving the main branch free of incomplete or untested work.
In the preceding figure, it was determined that all changes from feature/2 should be merged into the main branch.
That is, someone wanted the most recent commit for main to be the result of all the commits from both branches.
This outcome was accomplished by first running the git checkout main command to make sure that HEAD was on the most recent commit on the main branch, e8ce346.
Then, you ran the git merge feature/2 command.
This command created a new commit, 5749661, which included all the commits from feature/2 and all the commits from main.
It moved HEAD to the latest commit.
Sometimes, changes on more than one branch cannot merge automatically because each branch makes changes to the same parts of the same files. This situation is called a merge conflict and you need to manually edit the affected files to resolve it.
Some Git servers, such as GitHub, GitLab, and Bitbucket, allow you to protect branches from unwanted changes.
As a best practice you might protect certain branches on the remote repository, such as the main branch, to avoid merging unwanted changes to them from other branches created by developers.
These servers also often implement a feature called pull requests or merge requests. This provides a mechanism that you or other code reviewers can use to review and approve requests before the branch can be merged with the protected branch on the remote server.
Merge strategies, conflict resolution, and protection of branches, are out of scope for this course.
Use the git checkout command to move HEAD to any commit.
For example, the command git checkout 790dd94 moves HEAD to that commit.
Then, you can create a new branch starting with that commit, using the git branch and git checkout commands.
For example, you might choose to branch from a specific commit to ignore other recent changes to a branch.
You can create a branch and switch to it in a single step by running the git checkout command with the -b option:
[user@host ~]$ git checkout -b feature/3
Switched to a new branch 'feature/3'If you might need a commit in future, then you can use git tag to put a memorable label on it (such as tag/1.0 in the example) and then run the git checkout command on that tag to switch to the desired commit.
Initially, any branches that you create only exist in your local repository. If you want to share them with users of the original remote repository that you cloned, then you must push them to the repository.
The most common way to do this is to use the git push command with the --set-upstream (or -u) option to create a branch on the remote repository.
Your current local branch then tracks that branch.
For example, to push your new feature/3 branch to your remote repository, run the following commands:
[user@host ~]$git checkout feature/3Switched to branch 'feature/3' [user@host ~]$git push --set-upstream origin feature/3
The remote origin repository normally refers to the Git repository that you originally cloned.
This command indicates that you want to push the current branch to the origin repository, creating feature/3.
When the Git reviewers approve the push request, Git creates the feature/3 branch in the remote origin repository.
Running the git pull command on this branch pulls and merges any commits from the remote repository into your local copy of the branch.
Each Ansible project should have its own Git repository.
The structure of the files in that repository should follow the directory layout recommended at https://docs.ansible.com/ansible/6/user_guide/sample_setup.html#sample-directory-layout.
For example, the directory structure might appear as follows:
site.yml # main playbook
webservers.yml # playbook for webserver tier
dbservers.yml # playbook for dbserver tier
collections/ # holds Ansible Content Collections in directories
requirements.yml # specifies collections needed by this project
roles/
common/ # this hierarchy represents a "role"
tasks/ #
main.yml # <-- tasks file can include smaller files if warranted
handlers/ #
main.yml # <-- handlers file
templates/ # <-- files for use with the template resource
ntp.conf.j2 # <------- templates end in .j2
files/ #
bar.txt # <-- files for use with the copy resource
myscript.sh # <-- script files for use with the script resource
vars/ #
main.yml # <-- variables associated with this role
defaults/ #
main.yml # <-- default lower priority variables for this role
meta/ #
main.yml # <-- role dependencies
...additional roles...You can include roles and Ansible Content Collections in your Ansible project's Git repository.
In this case each collection is generally stored in subdirectories of the collections/ directory, and each role is stored in subdirectories of the roles directory.
There might also be a requirements.yml file in either or both of those directories that specifies collections or roles that are used by this project, which the ansible-galaxy command can download as needed.
Including roles and collections in an Ansible project directory structure takes some thought to manage well. If your roles and collections are managed as part of the playbook, then keeping them with the playbook can make sense. However, most roles and collections are meant to be shared by multiple projects, and if each project has its own copy of each role or collection, then they could diverge from each other, losing some of the benefits of using them.
A role or collection should have its own Git repository. Some people try to include these repositories in their project repositories by using an advanced feature of Git called submodules. Because submodules can be difficult to manage successfully, this is not a recommended approach.
A better approach is to set up requirements.yml files in your roles/ and collections/ directories and to use the ansible-galaxy command to populate this directory with the latest version of roles from automation hub, Ansible Galaxy, or their Git repositories before you run the project's playbooks.
Automation controller automatically updates the project with roles and collections based on the roles/requirements.yml and collections/requirements.yml files included in the Ansible project.
Not every file and directory in your Ansible project directory should be tracked by Git. You can configure Git to ignore files and directories that should not be tracked or committed.
For example, the ansible-navigator command generates or updates an ansible-navigator.log file, creates an artifact file each time it runs a playbook, and creates a .ssh directory.
None of these temporary files should be committed to the Git repository.
Likewise, if you use requirements.yml files with the ansible-galaxy command to populate the project's roles and collections directories, then you should configure Git to ignore the populated content.
To instruct Git to ignore specific files and paths in your project, add a .gitignore file at the top of your Ansible project directory.
Document this in a README file at the top of your Ansible project directory.
Each line in a .gitignore file represents a pattern to match that determines whether Git ignores a file in the repository.
Generally, lines that match file names indicate files that Git ignores.
Lines that start with an exclamation mark (!) indicate files that Git should not ignore, even if they were matched by a previous pattern.
Blank lines are permitted to improve readability, and any line that starts with a # is a comment.
Details on the pattern matching format for this file are documented in the gitignore(5) man page under "PATTERN FORMAT".
Pay particular attention to the distinction between single asterisks (*) and double asterisks (**) in these pattern matching lines.
(Double asterisks can match multiple directory levels.)
A sample .gitignore file could contain the following content:
roles/** !roles/requirements.yml collections/ansible_collections ansible-navigator.log *-artifact-* .ssh
gittutorial(7), git(1), and gitignore(5) man pages