jancy

Authoring Ansible playbooks in Java

Configuration concepts

Examples language:
The purpose of this document is to describe the building blocks of a jancy configuration and how they interact with each other. Basic knowledge of Ansible is assumed. If you would like to learn more about Ansible, please see the official tutorial. For more concrete examples, check the examples directory in the repo.

Contents

Hosts

The basic building block of Ansible inventory is a host - a single computer or a network device. When applying a configuration, Ansible will try to connect to the host by its name via ssh, but you can override that by providing the actual network name or ip.

For instance, you may have your databse server's IP hardcoded in /etc/hosts under the db alias and you want to use this alias through the configuration. One of the webservers, web01, may have a static ip and ssh running on custom port so you need to instruct Ansible about this with a variable. The other webserver might have dynamic IP discoverable throuugh DNS lookup, but you would prefer to use an alias web02 instead.

Host db = new Host("db");

Host web01 = new Host("web01")
    .vars(
        new HashMap<String, Object>() {{
            put("ansible_host", "10.0.0.102");
            put("ansible_port", "2222");
        }}
    );

Host web02 = new Host("web02")
    .vars(
        new HashMap<String, Object>() {{
            put("ansible_host", "web02.localdomain");
        }}
    );
val db = Host("db")

val web01 = Host("web01")
    .vars(
        mapOf(
            "ansible_host" to "10.0.0.102",
            "ansible_port" to "2222"
        )
    )

val web02 = Host("web02")
    .vars(
        mapOf(
            "ansible_host" to "web02.localdomain"
        )
    )
val db = new Host("db")

val web01 = new Host("web01")
    .vars(
        Map(
            "ansible_host" -> "10.0.0.102",
            "ansible_port" -> "2222"
        ).asJava
    )

val web02 = new Host("web02")
    .vars(
        Map(
            "ansible_host" -> "web02.localdomain"
        ).asJava
    )

Groups

Groups allow you to organize hosts into tree-like structure based on a certain criteria, such as geographic location or purpose.

For example, you might want to group your servers by the datacenter they are in, further grouped by the geographic region and then by the country:

Group atlanta = new Group("atlanta")
    .hosts(
        new Host("host1"), new Host("host2")
    );

Group raleigh = new Group("raleigh")
    .hosts(
        new Host("host3"), new Host("host4")
    );

Group southeast = new Group("southeast")
    .subgroups(
        atlanta, raleigh
    );

Group usa = new Group("usa")
    .subgroups(
        southeast,
        new Group("northeast"),
        new Group("southwest"),
        new Group("northwest")
    );
val atlanta = Group("atlanta")
    .hosts(
        Host("host1"), Host("host2")
    )

val raleigh = Group("raleigh")
    .hosts(
        Host("host3"), Host("host4")
    )

val southeast = Group("southeast")
    .subgroups(
        atlanta, raleigh
    )

val usa = new Group("usa")
    .subgroups(
        southeast,
        Group("northeast"),
        Group("southwest"),
        Group("northwest")
    )
val atlanta = new Group("atlanta")
    .hosts(
        new Host("host1"), new Host("host2")
    )

val raleigh = new Group("raleigh")
    .hosts(
        new Host("host3"), new Host("host4")
    )

val southeast = new Group("southeast")
    .subgroups(
        atlanta, raleigh
    )

val usa = new Group("usa")
    .subgroups(
        southeast,
        new Group("northeast"),
        new Group("southwest"),
        new Group("northwest")
    )

Inventories

For even more flexibility, you can split your hosts into several inventories so that you can apply the configuration to them independently.

For example, you may keep information about hosts in your production and stage environments separately.

Group webservers = new Group("webservers");

Group dbservers = new Group("dbservers");

Inventory production = new Inventory("production")
    .groups(
        webservers
            .hosts(
                new Host("web01-prod"),
                new Host("web02-prod"),
            ),
        dbservers
            .hosts(
                new Host("db-prod")
            )
    );

Host stageServer = new Host("stage");

Inventory stage = new Inventory("stage")
    .groups(
        webservers.hosts(stageServer),
        dbservers.hosts(stageServer)
    );
val webservers = Group("webservers")

val dbservers = Group("dbservers")

val production = Inventory("production")
    .groups(
        webservers
            .hosts(
                Host("web01-prod"),
                Host("web02-prod"),
            ),
        dbservers
            .hosts(
                Host("db-prod")
            )
    )

val stageServer = new Host("stage")

val stage = new Inventory("stage")
    .groups(
        webservers.hosts(stageServer),
        dbservers.hosts(stageServer)
    )
val webservers = new Group("webservers")

val dbservers = new Group("dbservers")

val production = new Inventory("production")
    .groups(
        webservers
            .hosts(
                new Host("web01-prod"),
                new Host("web02-prod"),
            ),
        dbservers
            .hosts(
                new Host("db-prod")
            )
    )

val stageServer = new Host("stage")

val stage = new Inventory("stage")
    .groups(
        webservers.hosts(stageServer),
        dbservers.hosts(stageServer)
    )

Variables

You can keep host-specific information which can then be referenced in tasks or used to fill in templates. Variables can be bound either to a host, a group, or all hosts in an inventory.

Host h1 = new Host("h1")
    .vars(
        new HashMap<String, Object>() {{
            put("ansible_port", "2222")
        }}
    );

Group g1 = new Group("g1")
    .vars(
        new HashMap<String, Object>() {{
            put("cache_path", "/var/cache")
        }}
    );

Inventory inventory = new Inventory("inventory")
    .vars(
        new HashMap<String, Object>() {{
            put("dns_server", "10.0.0.99")
        }}
    );
val h1 = Host("h1")
    .vars(
        mapOf(
            "ansible_port" to "2222"
        )
    )

val g1 = Group("h1")
    .vars(
        mapOf(
            "ansible_port" to "2222"
        )
    )
    
val inventory = Inventory("inventory")
    .vars(
        mapOf(
            "dns_server" to "10.0.0.99"
        )
    )
val h1 = new Host("h1")
    .vars(
        Map(
            "ansible_port" -> "2222"
        ).asJava
    )

val g1 = new Group("g1")
    .vars(
        Map(
            "cache_path" -> "/var/cache"
        ).asJava
    )

val inventory = new Inventory("inventory")
    .vars(
        Map(
            "dns_server" -> "10.0.0.99"
        ).asJava
    )

Tasks

Tasks specify which modules are supposed to be run on the target machine along with their parameters. For each module, there is a wrapper in jancy-common.

For instance, you might want to open a specific port on the firewall:

Task allowSmtp = new Task("Open smtp on the firewall")
    .action(new Ufw()
        .direction(Task.Direction.IN)
        .port("25")
        .proto(Task.Proto.TCP)
        .rule(Task.Rule.ALLOW)
    );
val allowSmtp = Task("Open smtp on the firewall")
    .action(Ufw()
        .direction(Task.Direction.IN)
        .port("25")
        .proto(Task.Proto.TCP)
        .rule(Task.Rule.ALLOW)
    )
val allowSmtp = new Task("Open smtp on the firewall")
    .action(new Ufw()
        .direction(Task.Direction.IN)
        .port("25")
        .proto(Task.Proto.TCP)
        .rule(Task.Rule.ALLOW)
    )

When you need to configure several resources in a similar way, you can use Ansible loops.

For example, to install several packages at once:

Task installPrerequisites = new Task("Install some pre-requisites")
    .action(new Apt()
        .state(Apt.State.PRESENT)
        .name("{{ item }}")
    )
    .withItems(
        "apache2",
        "subversion",
        "libapache2-mod-svn",
        "apache2-utils",
        "anacron"
    );
val installPrerequisites = Task("Install some pre-requisites")
    .action(Apt()
        .state(Apt.State.PRESENT)
        .name("{{ item }}")
    )
    .withItems(
        "apache2",
        "subversion",
        "libapache2-mod-svn",
        "apache2-utils",
        "anacron"
    )
val installPrerequisites = new Task("Install some pre-requisites")
    .action(new Apt()
        .state(Apt.State.PRESENT)
        .name("{{ item }}")
    )
    .withItems(
        "apache2",
        "subversion",
        "libapache2-mod-svn",
        "apache2-utils",
        "anacron"
    )

Invoking withItems with arbitrary objects is not supported yet, but you can use maps instead.

Task addSeveralUsers = new Task("Add several users")
    .action(new User()
        .name("{{ item.name }}")
        .state(User.State.PRESENT)
        .groups("{{ item.groups }}"))
    .withItems(
        new HashMap<String, Object>() {{
            put("name", "testuser1"),
            put("groups", "wheel")
        }},
        new HashMap<String, Object>() {{
            put("name", "testuser2"),
            put("groups", "root")
        }}
    );
val addSeveralUsers = Task("Add several users")
    .action(User()
        .name("{{ item.name }}")
        .state(User.State.PRESENT)
        .groups("{{ item.groups }}"))
    .withItems(
        mapOf(
            "name" to "testuser1",
            "groups" to "wheel"
        ),
        mapOf(
            "name" to "testuser2",
            "groups" to "root"
        ))
val addSeveralUsers = new Task("Add several users")
    .action(new User()
        .name("{{ item.name }}")
        .state(User.State.PRESENT)
        .groups("{{ item.groups }}"))
    .withItems(
        Map("name" -> "testuser1", "groups" -> "wheel").asJava,
        Map("name" -> "testuser2", "groups" -> "root").asJava
    )

If you intend to use jancy source code as the primary source of your configuration, and aren't concerned about human-readability of the generated YAML files, you may consider using the looping construct of your programming language instead.

String[] names = {
    "apache2", "subversion", "libapache2-mod-svn", "apache2-utils", "anacron"
};

Task[] installPrerequisites = new Task[names.length];
for(int i = 0; i < names.length; i++) {
    installPrerequisites[i] = new Task("Install " + name)
        .action(new Apt()
            .state(Apt.State.PRESENT)
            .name(name));
}
val installPrerequisites = listOf(
    "apache2", "subversion", "libapache2-mod-svn", "apache2-utils", "anacron"
).map {
    Task("Install $it")
        .action(Apt()
            .state(Apt.State.PRESENT)
            .name(it))
}.toTypedArray
val installPrerequisites = Seq(
    "apache2", "subversion", "libapache2-mod-svn", "apache2-utils", "anacron"
) map { name => 
    new Task(s"Install ${name}")
        .action(new Apt()
            .state(Apt.State.PRESENT)
            .name(name))
}

You also can apply a task only if a certain condition is met:

Task shutDownDebian = new Task("shut down Debian flavored systems")
    .action(new Command("/sbin/shutdown -t now"))
    .when("ansible_os_family == \"Debian\"");
val shutDownDebian = Task("shut down Debian flavored systems")
    .action(Command("/sbin/shutdown -t now"))
    .when("ansible_os_family == \"Debian\"")
val shutDownDebian = new Task("shut down Debian flavored systems")
    .action(new Command("/sbin/shutdown -t now"))
    .when("ansible_os_family == \"Debian\"")

Handlers

Handlers are tasks that are executed only if another task resulted in configuration change.

For example, you might want to restart your database service if the configuration changed.

Handler restartMysql = new Handler("Mysql should be restarted")
    .action(new Service()
        .name("mysqld")
        .state(Service.State.RESTARTED)
    );

Task configureMysql = new Task("Mysql config should exist in /etc/")
        .action(new File()
            .src("my.cnf")
            .dest("/etc/my.cnf")
        )
        .notify(restartMysqlHandler);
val restartMysql = Handler("Mysql should be restarted")
    .action(Service()
        .name("mysqld")
        .state(Service.State.RESTARTED)
    )

val configureMysql = Task("Mysql config should exist in /etc/")
        .action(File()
            .src("my.cnf")
            .dest("/etc/my.cnf")
        )
        .notify(restartMysqlHandler)
val restartMysql = new Handler("Mysql should be restarted")
    .action(new Service()
        .name("mysqld")
        .state(Service.State.RESTARTED)
    )

val configureMysql = new Task("Mysql config should exist in /etc/")
        .action(new File()
            .src("my.cnf")
            .dest("/etc/my.cnf")
        )
        .notify(restartMysqlHandler)

Files and templates

When relative paths are used in src parameter of file or template modules, Ansible will look for the content files in several directories, such as roles/$ROLE/files/, depending on the context.

If you put your content files in the jar in a package named after your configuration, jancy will unzip them into the output directory. So for instance, if you have a playbook called example_playbook, jancy will check if there are resources in example_playbook package and extract them.

Plays

Plays map machines from the inventory to their desired state.

For instance, you might have a play describing the configuration of all of your webservers.

Play webservers = new Play("webservers")
    .hosts(devWebserver, usWebservers)
    .tasks(
        installHttpd,
        uploadHttpdConfig,
        allowHttpdThroughFirewall
        //...
    )
    .handlers(
        restartHttpd,
        restartFirewall
        //...
    );
val webservers = Play("webservers")
    .hosts(devWebserver, usWebservers)
    .tasks(
        installHttpd,
        uploadHttpdConfig,
        allowHttpdThroughFirewall
        //...
    .handlers(
        restartHttpd,
        restartFirewall
        //...
    )
val webservers = new Play("webservers")
    .hosts(devWebserver, usWebservers)
    .tasks(
        installHttpd,
        uploadHttpdConfig,
        allowHttpdThroughFirewall
        //...
    .handlers(
        restartHttpd,
        restartFirewall
        //...
    )

Roles

If you want to reuse tasks and handlers in several plays, you can bundle them in a role.

You would quite often end up with a set of common tasks you would like to apply to every computer in your inventory.

Role common = new Role("common")
    .tasks(
        setUpUsers, installFirewall, allowSshThroughFirewall //, ...
    );

Role webserver = new Role("webserver")
    .tasks(
        installHttpd //, ...
    );

Role dbserver = new Role("dbserver")
    .tasks(
        installDb //, ...
    );

Play webservers = new Play("webservers")
    .hosts(
        devWebserver, usWebservers
    )
    .roles(
        common, webserver
    );

Play dbservers = new Play("dbservers")
    .hosts(
        devDb, productionDb
    )
    .roles(
        common, dbserver
    );
val common = Role("common")
    .tasks(
        setUpUsers, installFirewall, allowSshThroughFirewall //, ...
    )

val webserver = Role("webserver")
    .tasks(
        installHttpd //, ...
    )

val dbserver = Role("dbserver")
    .tasks(
        installDb //, ...
    )

val webservers = Play("webservers")
    .hosts(
        devWebserver, usWebservers
    )
    .roles(
        common, webserver
    )

val dbservers = Play("dbservers")
    .hosts(
        devDb, productionDb
    )
    .roles(
        common, dbserver
    )
val common = new Role("common")
    .tasks(
        setUpUsers, installFirewall, allowSshThroughFirewall //, ...
    )

val webserver = new Role("webserver")
    .tasks(
        installHttpd //, ...
    )

val dbserver = new Role("dbserver")
    .tasks(
        installDb //, ...
    )

val webservers = new Play("webservers")
    .hosts(
        devWebserver, usWebservers
    )
    .roles(
        common, webserver
    )

val dbservers = new Play("dbservers")
    .hosts(
        devDb, productionDb
    )
    .roles(
        common, dbserver
    )

Playbooks and PlaybookFactories

jancy allows you to keep code and files for several different playbooks in a one jar. Each playbook will be output to a separate directory.

On startup, jancy will look for classes implementing the PlaybookFactory interface in the jar and invoke build on each of them.

Supposing you have classes:

public class Example1PlaybookFactory implements PlaybookFactory {
    @Override
    public Playbook build() {
        return new Playbook("examplename1")
            //...
            ;
    }
}

public class Example2PlaybookFactory implements PlaybookFactory {
    @Override
    public Playbook build() {
        return new Playbook("examplename2")
            //...
            ;
    }
}
class Example1PlaybookFactory : PlaybookFactory {
    override fun build(): Playbook {
        Playbook("examplename1")
            //...
    }
}

class Example2PlaybookFactory : PlaybookFactory {
    override fun build(): Playbook {
        Playbook("examplename2")
            //...
    }
}
class Example1PlaybookFactory extends PlaybookFactory {
    public override buildConfiguration: Playbook =
        new Playbook("examplename1")
            //...
}

class Example2PlaybookFactory extends PlaybookFactory {
    public override buildConfiguration: Playbook =
        new Playbook("examplename2")
            //...
}

Executing jancy:

$ jancy -j playbooks.jar -o /tmp/

will create /tmp/examplename1 and /tmp/examplename2 directories and save the resulting YAML files to each of them.