Going Further with Ansible and Juniper

Going Further with Ansible and Juniper
In: Ansible-Course
Table of Contents

This post is part of an ongoing series on Ansible, and you can find all the other posts in the series here.

In the previous part, we backed up the config of our Junos routers in two different ways. First, we used the junos_config module, which saved the config as set commands. Then, to get a copy in the curly bracket format, we ran show configuration with the junos_command module and saved the output to a file. In this section, I'll show you a more direct way to do it, using a module called juniper.device.config, which can retrieve the config and save it in whichever format we like.

Installing the Extra Packages

juniper.device.config is built on Juniper's PyEZ library, and it needs two extra Python packages, on top of the ones we installed in the previous part.

uv add junos-eznc jxmlease
  • junos-eznc - Juniper's PyEZ library, which the module uses to connect to the router.
  • jxmlease - Another library for working with XML, which PyEZ relies on.

The Playbook

The module doesn't create the directory for the backups, so let's create it first.

mkdir -p backups_native

Next, create a new playbook called backup_native.yml.

---
- name: Back up the Junos config with juniper.device.config
  hosts: junos
  gather_facts: false

  vars:
    ansible_connection: juniper.device.pyez

  tasks:
    - name: Save the config in text format
      juniper.device.config:
        retrieve: committed
        format: text
        dest_dir: "{{ playbook_dir }}/backups_native"

The connection

So far, we've connected to our Juniper routers using ansible.netcommon.netconf, which we set in group_vars/junos.yml. The PyEZ-based modules don't work with this connection. Instead, they use a connection called juniper.device.pyez, which talks to the router using the PyEZ library. It still uses NETCONF behind the scenes, and it reads the IP address, username, and password from the same variables as before, ansible_host, ansible_user, and ansible_password, so we don't need to change anything in the inventory or group_vars.

You might be wondering why we set ansible_connection under vars, rather than using the connection keyword on the play, such as connection: juniper.device.pyez. Both of them control exactly the same thing, but one is a playbook keyword, and the other is a variable, and in Ansible, variables always take priority over playbook keywords. Since group_vars/junos.yml already sets ansible_connection to NETCONF, it would override the connection keyword, and Ansible would still try to use NETCONF. Variables under vars in the play, on the other hand, take priority over group_vars, so this overrides the connection for this play only.

If you'd like to read more about this, check out the Ansible documentation which explains how variables, playbook keywords, command-line options, and configuration settings override each other.

Retrieving the config

  • retrieve - Which config to retrieve. committed is the active config, as opposed to candidate, which would include any uncommitted changes.
  • format - The format to save the config in. text is the curly bracket format, but you can also use set, json, or xml.
  • dest_dir - The directory to save the backups in. The module names each file after the router, so we get j1.config and j2.config.

For dest_dir, we're using a built-in variable called playbook_dir, which contains the path to the directory the playbook is in. This makes sure the backups always end up in our project, no matter where the module runs from.

Running the Playbook

ansible-playbook backup_native.yml
PLAY [Back up the Junos config with juniper.device.config]

TASK [Save the config in text format] ************
ok: [j2]
ok: [j1]

PLAY RECAP ***************************************
j1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Notice that the task shows ok, rather than changed. With junos_config, the backup task always showed changed, since a new file was written on the control node. juniper.device.config only reports changed when the config on the router changes, and since we only retrieved the config, nothing on the routers changed.

We now have j1.config and j2.config in the backups_native directory, with each router's full config in the curly bracket format.

## Last commit: 2026-10-31 15:25:20 UTC by admin
version 24.2R1-S2.5;
system {
    host-name j1;
    ...
}
interfaces {
    ge-0/0/0 {
        description j2;
        unit 0 {
            family inet {
                address 10.200.12.1/24;
            }
        }
    }
    ...
}
...
protocols {
    ospf {
        area 0.0.0.0 {
            interface ge-0/0/0.0;
            interface lo0.0 {
                passive;
            }
        }
    }
}

Other Ways to Set the Connection

There are a few different ways to set the connection for the PyEZ-based modules, and whether they work depends on what's already set in group_vars.

  • NETCONF in group_vars, and ansible_connection under vars in the playbook - Works, and this is what we're using in this part. The variable in the playbook overrides the one in group_vars.
  • NETCONF in group_vars, and the connection keyword in the playbook - Doesn't work. The variable in group_vars overrides the keyword, so Ansible still tries to use NETCONF.
  • No connection in group_vars, and connection: juniper.device.pyez in the playbook - Works, with no extra settings.
  • No connection in group_vars, and connection: local in the playbook - Works, but you also need to set ansible_python_interpreter. This is what the examples in the module's documentation use.

The last one needs a bit of explaining. With connection: local, Ansible runs the module as a Python script on the control node, and it has to decide which Python to use. On my Mac, it picked the Python installed by Homebrew, rather than the one in our virtual environment, and since PyEZ is only installed in the virtual environment, the module failed.

ansible_python_interpreter is a variable that tells Ansible which Python to use when it runs a module, and ansible_playbook_python is a built-in variable that contains the path to the Python running ansible-playbook, which is the one in our virtual environment. So adding the following under vars makes sure the module uses the same Python as the playbook.

  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"

You don't need this with connection: juniper.device.pyez, since Ansible handles that connection itself, and uses the same Python as the playbook.

I went with the first option, since it keeps NETCONF as the default for all the playbooks from the previous part, and only overrides it for the playbooks that use PyEZ. If you'd rather not have a connection in group_vars at all, the third option works just as well, but you'll need to add connection: ansible.netcommon.netconf to the playbooks that use the other Juniper modules instead.

Backing Up in Other Formats

As we mentioned earlier, juniper.device.config can also save the config as set commands, JSON, or XML, by changing the format option. Let's try all three in one go, using a loop. First, create a directory for each format.

mkdir -p backups_native/set backups_native/json backups_native/xml

Then, create a new playbook called backup_formats.yml.

---
- name: Back up the Junos config in different formats
  hosts: junos
  gather_facts: false

  vars:
    ansible_connection: juniper.device.pyez

  tasks:
    - name: Save the config in each format
      juniper.device.config:
        retrieve: committed
        format: "{{ item }}"
        dest_dir: "{{ playbook_dir }}/backups_native/{{ item }}"
      loop:
        - set
        - json
        - xml

The loop runs the task three times, once for each format, and saves each one in its own directory.

ansible-playbook backup_formats.yml
PLAY [Back up the Junos config in different formats]

TASK [Save the config in each format] ************
ok: [j1] => (item=set)
ok: [j2] => (item=set)
ok: [j2] => (item=json)
ok: [j1] => (item=json)
ok: [j2] => (item=xml)
ok: [j1] => (item=xml)

PLAY RECAP ***************************************
j1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Each format gets its own file extension, .set, .json, and .xml, so we end up with j1.set, j1.json, and j1.xml, and the same for j2. Here's what the start of each file looks like for j1.

set version 24.2R1-S2.5
set system host-name j1
set system services netconf ssh
set system ntp server 10.10.10.20
set interfaces ge-0/0/0 description j2
set interfaces ge-0/0/0 unit 0 family inet address 10.200.12.1/24
...
{"configuration": {"version": "24.2R1-S2.5", "system": {"host-name": "j1", ...}, ...}}
<configuration commit-localtime="2026-10-31 15:25:20 UTC" commit-user="admin">
  <version>24.2R1-S2.5</version>
  <system>
    <host-name>j1</host-name>
    ...

Backing Up Part of the Config

Sometimes, you don't need a copy of the whole config, just one part of it. For example, you might want a backup of the OSPF config before making a change to it. juniper.device.config has a filter option, which lets us retrieve only the part of the config we're interested in.

So far, we've also been creating the backup directories by hand with mkdir. This time, let's get Ansible to create the directory for us. Create a new playbook called backup_filter.yml.

---
- name: Back up part of the Junos config
  hosts: junos
  gather_facts: false

  vars:
    ansible_connection: juniper.device.pyez
    backup_dir: "{{ playbook_dir }}/backups_native/ospf"

  tasks:
    - name: Create the backup directory
      ansible.builtin.file:
        path: "{{ backup_dir }}"
        state: directory
      delegate_to: localhost
      run_once: true

    - name: Save only the OSPF config
      juniper.device.config:
        retrieve: committed
        format: set
        filter: protocols/ospf
        dest_dir: "{{ backup_dir }}"

Since both tasks use the same path, I've moved it into a variable called backup_dir, under vars. That way, if we ever want to change where the backups are saved, we only need to change it in one place.

Creating the directory

The first task uses a new module called file, which is a built-in module for managing files and directories. It has two options here.

  • path - The file or directory we want to manage.
  • state - What it should look like. directory means it should be a directory. If it doesn't exist, the module creates it, and if it already exists, it does nothing. So, just like the other modules we've used, it's safe to run every time.

We also need to think about where this task runs. Our play targets the routers, but the directory needs to be created on the control node, not on the routers. So, just like the copy task in the previous part, we use delegate_to: localhost. Both routers save their backups to the same directory, so there's no point trying to create it twice. Adding run_once: true means the task only runs once, for the first router in the play.

Filtering the config

The second task is almost the same as our earlier backups, with one new option, filter. It tells the module which part of the config to retrieve, written as the path through the config hierarchy, separated by /. protocols/ospf means the ospf section under protocols, which is the same as running show configuration protocols ospf on the CLI. I've also used the set format, since it makes a short backup like this easy to read.

Running the playbook

ansible-playbook backup_filter.yml
PLAY [Back up part of the Junos config] **********

TASK [Create the backup directory] ***************
changed: [j1 -> localhost]

TASK [Save only the OSPF config] *****************
ok: [j1]
ok: [j2]

PLAY RECAP ***************************************
j1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

The first task created the directory, so it shows changed. It only ran once, for j1, and you can see j1 -> localhost, since it was delegated to the control node. That's also why j2 only has ok=1 in the PLAY RECAP.

If we look at the backup for j1, it only contains the OSPF config.

set protocols ospf area 0.0.0.0 interface ge-0/0/0.0
set protocols ospf area 0.0.0.0 interface lo0.0 passive

If we run the playbook again, the directory already exists, so the first task shows ok this time.

PLAY [Back up part of the Junos config] **********

TASK [Create the backup directory] ***************
ok: [j1 -> localhost]

TASK [Save only the OSPF config] *****************
ok: [j1]
ok: [j2]

PLAY RECAP ***************************************
j1 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Making a Change and Saving the Diff

So far, we've only used juniper.device.config to retrieve the config. But it can also make changes, and it has a couple of nice extras. It can make the change in a private copy of the candidate config, and it can save the differences to a file, so we keep a record of exactly what was changed on each router.

Let's add another NTP server to both routers. Create a new playbook called change_with_diff.yml.

---
- name: Make a change and save the diff
  hosts: junos
  gather_facts: false

  vars:
    ansible_connection: juniper.device.pyez
    diff_dir: "{{ playbook_dir }}/diffs"

  tasks:
    - name: Create the diff directory
      ansible.builtin.file:
        path: "{{ diff_dir }}"
        state: directory
      delegate_to: localhost
      run_once: true
      check_mode: false

    - name: Add an NTP server
      juniper.device.config:
        config_mode: private
        load: set
        lines:
          - set system ntp server 10.10.10.21
        comment: "Add NTP server with juniper.device.config"
        dest_dir: "{{ diff_dir }}"

The first task creates a directory called diffs on the control node, just like we did in the previous section. We'll come back to check_mode: false shortly.

The second task makes the change, using a few options we haven't seen before.

  • config_mode - How the module opens the candidate config. By default, it locks the candidate config, so nobody else can make changes while our task is running. With private, it opens a private copy of the candidate config instead, the same as running configure private on the CLI. Our change is made in that private copy, so it doesn't get mixed up with any uncommitted changes someone else might be working on at the same time.
  • load - How the new config is loaded. set tells the module that our lines are set commands.
  • lines - The change itself, written as set commands, just like on the CLI.
  • comment - Our own commit comment.
  • dest_dir - Earlier, we used this to save the backups. Since we're making a change this time, the module saves the differences to this directory instead, in a file named after each router, such as j1.diff.

Running in Check Mode

As always, let's run it in check mode first. We're also adding --diff, which tells Ansible to show the differences on screen as well. The module saves the .diff files either way, but --diff lets us see the change straight away.

ansible-playbook change_with_diff.yml --check --diff

The first time I ran this, the second task failed with an error saying it couldn't save the .diff file. That's because of how check mode works. In check mode, Ansible doesn't make any changes at all, and that includes the file task. It reported that it would create the directory, but it didn't actually create it. So when the second task tried to save the diff, the directory didn't exist.

That's why we used check_mode: false . Adding this to a task tells Ansible to always run that task for real, even when the playbook is in check mode. Creating an empty directory on the control node doesn't change anything on our routers, so it's perfectly safe to do. With check_mode: false added, the check run works as expected.

PLAY [Make a change and save the diff] ***********

TASK [Create the diff directory] *****************
--- before
+++ after
@@ -1,4 +1,4 @@
 {
     "path": ".../ansible_junos/diffs",
-    "state": "absent"
+    "state": "directory"
 }

changed: [j1 -> localhost]

TASK [Add an NTP server] *************************

[edit system ntp]
     server 10.10.10.20 { ... }
+    server 10.10.10.21;
changed: [j1]

[edit system ntp]
     server 10.10.10.20 { ... }
+    server 10.10.10.21;
changed: [j2]

PLAY RECAP ***************************************
j1 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

The directory was created for real, and you can even see the diff for it, showing state going from absent to directory. The second task shows exactly what would be added to each router, 10.10.10.21, next to the existing 10.10.10.20. Nothing has changed on the routers yet, but the module has already saved the diff files, so we have a record of what would change before changing anything.

cat diffs/j1.diff

[edit system ntp]
     server 10.10.10.20 { ... }
+    server 10.10.10.21;

Applying the Change

Now let's run it for real.

ansible-playbook change_with_diff.yml --diff
PLAY [Make a change and save the diff] ***********

TASK [Create the diff directory] *****************
ok: [j1 -> localhost]

TASK [Add an NTP server] *************************

[edit system ntp]
     server 10.10.10.20 { ... }
+    server 10.10.10.21;
changed: [j2]

[edit system ntp]
     server 10.10.10.20 { ... }
+    server 10.10.10.21;
changed: [j1]

PLAY RECAP ***************************************
j1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

This time, the directory already exists, so the first task shows ok, and the NTP server was added to both routers. Let's check j1.

admin@j1> show configuration system ntp
server 10.10.10.20;
server 10.10.10.21;

admin@j1> show system commit
0   2026-11-01 11:41:19 UTC by admin via netconf
    Add NTP server with juniper.device.config
1   2026-10-31 15:25:20 UTC by admin via netconf
    Replace NTP servers
...

The new server is there, and the change was made in a single commit, with our comment at the top of the commit history.

If we run the playbook once more, both routers show ok, since 10.10.10.21 is already configured.

TASK [Add an NTP server] *************************
ok: [j2]
ok: [j1]

The .diff files aren't overwritten when nothing changes, so they still contain the diff from the last real change. This makes them a handy record of the last change made to each router, which you could keep alongside your backups.

What Does --diff Do?

We've used --diff a few times now, so let's take a moment to look at what it actually does.

By default, when you run a playbook, Ansible only tells you whether each task made a change or not, with ok or changed. It doesn't tell you what the change actually was. --diff is an option for ansible-playbook that asks Ansible to show the differences as well, so for every task that changes something, you can see exactly what was added or removed.

To show the difference, I added another NTP server, 10.10.10.22, to our playbook, and ran it without --diff.

ansible-playbook change_with_diff.yml
TASK [Add an NTP server] *************************
changed: [j1]
changed: [j2]

The task shows changed, but nothing about the change itself. Compare that with our earlier run with --diff, which also showed exactly what was added.

TASK [Add an NTP server] *************************

[edit system ntp]
     server 10.10.10.20 { ... }
+    server 10.10.10.21;
changed: [j2]

--diff only changes what's shown on screen. It doesn't change what the playbook actually does. Even though nothing about the change was shown in the run without --diff, the .diff files were still saved, and diffs/j1.diff now shows the new server being added.

cat diffs/j1.diff

[edit system ntp]
     server 10.10.10.21 { ... }
+    server 10.10.10.22;

That's because dest_dir is an option of the module itself, so it works whether or not you use --diff.

You can use --diff on its own, or together with --check.

  • --diff - Makes the changes, and shows you exactly what was changed.
  • --check --diff - Doesn't make any changes, but shows you exactly what would be changed.

Keep in mind that --diff only shows something if the module supports it. The Juniper modules are really good at this, since Junos can compare the candidate config with the active config itself, which is the same thing show | compare does on the CLI. Some modules don't support it at all, in which case you'll only see ok or changed. You can check whether a module supports it in the Attributes section of its documentation, under diff_mode.

What About Cisco?

The Juniper modules can show us a diff so easily because Junos has a candidate config, which it can compare with the active config. Cisco IOS doesn't have a candidate config, so the ios_config module needs to be told what to compare against when we use --diff. It does this with an option called diff_against.

To try it out, I used r1 from the OSPF lab, and added an NTP server with ios_config. Create a new playbook called cisco_diff.yml.

---
- name: Test diff_against with ios_config
  hosts: r1
  gather_facts: false

  tasks:
    - name: Add an NTP server
      cisco.ios.ios_config:
        lines:
          - ntp server 10.10.10.30
        diff_against: running

diff_against has three possible values, and we'll look at the first two here.

  • running - Compares the running config before and after the task, so you see exactly what the task changed.
  • startup - Compares the running config with the startup config, which shows every change that hasn't been saved yet.
  • intended - Compares the running config with a config you provide yourself.

Just like with Juniper, the diff is only shown when we run the playbook with --diff.

ansible-playbook cisco_diff.yml --diff
PLAY [Test diff_against with ios_config] *********

TASK [Add an NTP server] *************************
--- before
+++ after
@@ -74,4 +74,5 @@
 line vty 0 4
  login local
  transport input ssh
+ntp server 10.10.10.30
 end

changed: [r1]

PLAY RECAP ***************************************
r1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

The diff looks a little different from the Juniper one, but it shows the same kind of information. It's a standard unified diff, the same format you'd get from the diff command on Linux.

  • --- before and +++ after - The running config before and after the task.
  • @@ -74,4 +74,5 @@ - Where in the config the change is, starting at line 74.
  • +ntp server 10.10.10.30 - The line that was added. The lines around it, without a + or -, are only shown for context, so you can see where in the config the change was made.

Comparing with the Startup Config

Now, let's change the playbook to add another NTP server, 10.10.10.31, and set diff_against to startup.

    - name: Add an NTP server
      cisco.ios.ios_config:
        lines:
          - ntp server 10.10.10.31
        diff_against: startup
ansible-playbook cisco_diff.yml --diff
PLAY [Test diff_against with ios_config] *********

TASK [Add an NTP server] *************************
--- before
+++ after
@@ -74,4 +74,6 @@
 line vty 0 4
  login local
  transport input ssh
+ntp server 10.10.10.30
+ntp server 10.10.10.31
 end

changed: [r1]

PLAY RECAP ***************************************
r1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

This time, the diff shows both NTP servers, even though this task only added 10.10.10.31. That's because it's comparing the running config with the startup config, and we haven't saved the config since adding 10.10.10.30. So startup isn't really about what a single task changed. It shows every unsaved change on the device, whoever made it.

To confirm this, let's save the config on r1.

r1#show run | sec ntp
ntp server 10.10.10.30
ntp server 10.10.10.31
r1#write memory
Building configuration...
[OK]

Running the playbook again now shows ok, with no diff at all, since 10.10.10.31 is already configured, and the running and startup configs are now the same.

PLAY [Test diff_against with ios_config] *********

TASK [Add an NTP server] *************************
ok: [r1]

PLAY RECAP ***************************************
r1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Commit Check

Before committing a change, juniper.device.config runs a commit check by default. This asks Junos to check that the candidate config is valid, without actually committing it. If the check fails, the module stops straight away, and nothing is committed.

To see this in action, let's make a change that Junos won't accept. We'll tell OSPF to use an export policy called DOES-NOT-EXIST, which, as the name suggests, doesn't exist anywhere in the config. Junos is happy to load this line into the candidate config, but it'll reject it when it checks the config, since the policy isn't defined. Create a new playbook called commit_check.yml.

---
- name: Test the commit check
  hosts: j1
  gather_facts: false

  vars:
    ansible_connection: juniper.device.pyez

  tasks:
    - name: Add an OSPF export policy that does not exist
      juniper.device.config:
        load: set
        lines:
          - set protocols ospf export DOES-NOT-EXIST
        comment: "Test commit check"

We don't need to add anything to turn on the commit check, since it's enabled by default.

ansible-playbook commit_check.yml --diff
PLAY [Test the commit check] *********************

TASK [Add an OSPF export policy that does not exist]
[ERROR]: Task failed: Module failed: Failure checking the
configuraton: CommitError(edit_path: None, bad_element: None,
message: error: Policy error: Policy DOES-NOT-EXIST referenced
but not defined
error: configuration check-out failed)

fatal: [j1]: FAILED! => {...}

PLAY RECAP ***************************************
j1 : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0

The task failed, and the error comes straight from Junos, telling us exactly what's wrong, Policy DOES-NOT-EXIST referenced but not defined. Let's check j1.

admin@j1> show configuration protocols ospf
area 0.0.0.0 {
    interface ge-0/0/0.0;
    interface lo0.0 {
        passive;
    }
}

admin@j1> show system commit
0   2026-11-01 11:50:21 UTC by admin via netconf
    Add NTP server with juniper.device.config
1   2026-11-01 11:41:19 UTC by admin via netconf
    Add NTP server with juniper.device.config
...

There's no export line under OSPF, and there's no new commit at the top of the history. The most recent commit is still the NTP change from the previous section, so nothing was changed on the router.

Checking a Change Without Committing It

You can also use the commit check on its own, to make sure a change is valid without actually committing it. This is really useful when you're preparing changes ahead of a maintenance window, since you can check that the config is valid well before the change itself. To do this, we set commit to false.

Let's update the task in commit_check.yml to add another NTP server, this time without committing it.

    - name: Validate a change without committing it
      juniper.device.config:
        load: set
        lines:
          - set system ntp server 10.10.10.23
        commit: false
ansible-playbook commit_check.yml --diff
PLAY [Test the commit check] *********************

TASK [Validate a change without committing it] ***

[edit system ntp]
     server 10.10.10.22 { ... }
+    server 10.10.10.23;
changed: [j1]

PLAY RECAP ***************************************
j1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

The module loaded the change, showed us the diff, and the commit check passed. But if we look at j1, 10.10.10.23 isn't there.

admin@j1> show configuration system ntp
server 10.10.10.20;
server 10.10.10.21;
server 10.10.10.22;

Since we set commit to false, the change was checked and then discarded, without being committed. You might notice that the task still shows changed, even though nothing on the router changed. That's because the module reports whether the config would change, based on the diff, rather than whether it actually committed anything. So with commit: false, changed simply means the change is valid, and it would have changed the config.

commit confirmed with juniper.device.config

In the previous part, we used commit confirmed with the junos_config module, and juniper.device.config supports it too. It works in a very similar way, but there's one difference in how the change is confirmed, so let’s have a look at it.

Let's use the same approach as before, adding a new NTP server to both routers, and asking whether to keep the change. Create a new playbook called config_commit_confirmed.yml.

---
- name: Commit confirmed with juniper.device.config
  hosts: junos
  gather_facts: false

  vars:
    ansible_connection: juniper.device.pyez

  tasks:
    - name: Add an NTP server with commit confirmed
      juniper.device.config:
        load: set
        lines:
          - set system ntp server 10.10.10.24
        comment: "Add NTP server - commit confirmed"
        confirmed: 2

    - name: Ask whether to keep the change
      ansible.builtin.pause:
        prompt: "Check the routers. Type yes to keep the change, anything else to let it roll back"
      register: answer

    - name: Confirm the commit
      juniper.device.config:
        check: true
        diff: false
        commit: false
      when: hostvars[ansible_play_hosts[0]]['answer']['user_input'] == 'yes'

The first task makes the change, with confirmed: 2. This does exactly the same thing as confirm: 2 with junos_config, it commits the change with commit confirmed 2, so Junos rolls it back after two minutes, unless it's confirmed. Only the name of the option is different.

The second task is the same pause we used in the previous part, along with hostvars in the when condition, so the answer works for both routers.

The last task is where the difference is. With junos_config, we confirmed the change with confirm_commit: true, which made another commit. With juniper.device.config, we confirm it with a commit check instead. On Junos, running a commit check is enough to confirm a pending commit confirmed, so there's no need to commit again. Here's what each option does.

  • check: true - Runs a commit check, which is what confirms the change.
  • diff: false - Skips the diff, since there's nothing new to compare.
  • commit: false - Doesn't commit anything.

The name can be a little misleading. The confirmed option only starts a commit confirmed, it doesn't confirm one. According to the module's documentation, to confirm a pending change, you need to run the module again with check: true and commit: false, like we do in the last task.

Let's run the playbook, and type yes at the prompt.

ansible-playbook config_commit_confirmed.yml --diff
PLAY [Commit confirmed with juniper.device.config]

TASK [Add an NTP server with commit confirmed] ***

[edit system ntp]
     server 10.10.10.22 { ... }
+    server 10.10.10.24;
changed: [j1]

[edit system ntp]
     server 10.10.10.22 { ... }
+    server 10.10.10.24;
changed: [j2]

TASK [Ask whether to keep the change] ************
[Ask whether to keep the change]
Check the routers. Type yes to keep the change, anything else to let it roll back:
ok: [j1]

TASK [Confirm the commit] ************************
ok: [j1]
ok: [j2]

PLAY RECAP ***************************************
j1 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
j2 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Notice that the confirm task shows ok, rather than changed. Since it only ran a commit check, nothing new was committed. After waiting more than two minutes, let's check j1.

admin@j1> show system commit
0   2026-11-01 12:13:14 UTC by admin via netconf commit confirmed, rollback in 2mins
    Add NTP server - commit confirmed
1   2026-11-01 11:50:21 UTC by admin via netconf
    Add NTP server with juniper.device.config
...

admin@j1> show configuration system ntp
server 10.10.10.20;
server 10.10.10.21;
server 10.10.10.22;
server 10.10.10.24;

The new NTP server is still there, and there's no rollback at the top of the commit history, so the change was confirmed. But unlike with junos_config, there's no separate commit for the confirm. The commit confirmed itself is the most recent commit, which keeps the commit history a little cleaner.

This post is already quite long, so I'll stop here. In a future part, we'll look at JSNAPy, a tool from Juniper that takes snapshots of the network before and after a change and compares them, so you can make sure nothing broke along the way.

This post is part of an ongoing series on Ansible, and you can find all the other posts in the series here.

Written by
Suresh Vinasiththamby
Tech enthusiast sharing Networking, Cloud & Automation insights. Join me in a welcoming space to learn & grow with simplicity and practicality.
Comments
More from Packetswitch
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.