A working example

In this example we’ll go over a simple example for how you might use saltant to run a task. To make HTTP requests to our saltant server, we’ll use HTTPie, which is essentially curl but prettier and probably a bit less powerful (but powerful enough for our purposes).

The code for this example can be found at github.com/saltant-org/saltant-working-example.

Objective

We’re a programmer for Anagram Inc., a fictional software company specializing in anagrams, and our boss has instructed us to write an anagram generating task for our company’s saltant server.

The task must satify the following criteria: given a word argument, which might be, for example, “binary”, we must create a results file binary.txt that contains all one-word anagrams of “binary”:

  • binary
  • brainy

An anagram generator

Before diving into any saltant specific logic, we need to solve the anagram problem. To find all anagrams of a word, we need something to permute the letters of the word and something to verify that a permutation represents an English word.

Let’s use Python to write the task. To generate permutations we can use the permutations function from the itertools library, which is part of the Python Standard Library; and to verify that permutations are English words we can use a set of all English words from the english-words Python package on PyPI.

The following code does the job:

#!/usr/bin/env python3
"""Find anagrams of words."""

from itertools import permutations
from typing import Set
from english_words import english_words_lower_alpha_set


def find_anagrams(word: str) -> Set[str]:
    """Find all one-word anagrams of a word.
    Arg:
        word: a string containing a word to find anagrams of.
    Returns:
        A set containing all anagrams of the word, in lowercase. This is
        guaranteed to at least contain the word itself, provided the
        word passed in is an English word.
    """
    # Find all permutations of the word
    perms = {''.join(p) for p in permutations(word.lower())}

    # Filter out all non-English permutations
    anagrams = {word for word in perms
                if word in english_words_lower_alpha_set}

    # Return the results
    return anagrams

We still haven’t dealt with input arguments and saved our results to an output file. We’ll get to that next.

Getting input arguments

For saltant tasks, arguments come in the form of JSON. That is, if your script is called main.py, your script will be called as in the following:

$ ./main.py '{"arg1": "val1", "arg2": "val2"}'

where arg{1,2} are arguments and val{1,2} are their corresponding values.

The general strategy is to get all the arguments at runtime with something like

import json
import sys


if __name__ == '__main__':
    # Get the arguments as a JSON string
    raw_args = sys.argv[1]

    # Parse the raw JSON and return a dictionary of the parsed
    # arguments
    args = json.loads(raw_args)

Now all of the arguments we need are in args. Additionally, we can do any validation we need to ensure that the values in args are okay (but let’s not worry about that here).

Tying this into our anagram example, our code becomes

#!/usr/bin/env python3
"""Find anagrams of words."""

from itertools import permutations
import json
import sys
from typing import Set
from english_words import english_words_lower_alpha_set


def find_anagrams(word: str) -> Set[str]:
    """Find all one-word anagrams of a word.

    Arg:
        word: a string containing a word to find anagrams of.
    Returns:
        A set containing all anagrams of the word, in lowercase. This is
        guaranteed to at least contain the word itself, provided the
        word passed in is an English word.

    """
    # Find all permutations of the word
    perms = {''.join(p) for p in permutations(word.lower())}

    # Filter out all non-English permutations
    anagrams = {word for word in perms
                if word in english_words_lower_alpha_set}

    # Return the results
    return anagrams


if __name__ == '__main__':
    # Get the arguments as a raw JSON string
    raw_args = sys.argv[1]

    # Parse the raw JSON and return a dictionary of the parsed
    # arguments
    args = json.loads(raw_args)

    # Find anagrams
    results = find_anagrams(args['word'])

Notice that we’re making the assumption that word is going to be passed into the JSON arguments; we will enforce this assumption when we register our task to our saltant server.

Containerizing our code

The next obvious step is to save our anagram results somewhere. Where will we put them? Well, eventually they’re going to need to end up in a container, so we might as well define our container first.

Inside of our container we need to make sure the following constraints are satisfied:

  1. All necessary Python requirements are installed
  2. We have somewhere to put our results
  3. We have somewhere to put our logs

To satisfy the first requirement we need a (Python) requirements file, which will contain the following:

english-words==1.0.1

since the english-words package is our only dependency outside of the Python Standard Library.

At this point we have two files:

  • main.py contains our anagram code
  • requirements.txt contains our Python requirements

Before we define our containers, make sure that main.py is executable:

$ chmod +x main.py

saltant needs its entry point to be executable, which is now taken care of.

Defining a Docker container

Now let’s package our code into a Docker container, keeping in mind our requirements above. (We’ll do the same thing but with a Singularity container later.) Our Dockerfile might look like the following:

# Use an official Python runtime as a parent image
FROM python:3-slim

# Set the working directory to /app
WORKDIR /app

# Copy the current directory contents into the container in its /app/
# directory
ADD main.py requirements.txt /app/

# Install Python requirements
RUN pip install -r requirements.txt

# Logs can be collected here
RUN mkdir /logs

# Results can be collected here
RUN mkdir /results

We can build this recipe and push it to Docker Hub (I’m using my user for convenience here) with

$ docker build -t saltant/saltant-working-example .
$ docker push saltant/saltant-working-example

or we can set up automated builds from a Git repository (which has been set up for the source code for this example at github.com/saltant-org/saltant-working-example).

Defining a Singularity container

Now we’ll repeat the previous section, but use a Singularity container instead of a Docker container. Our Singularity file might look like the following:

# Pull from Ubuntu 16.04 image
Bootstrap: debootstrap
OSVersion: xenial
MirrorURL: http://us.archive.ubuntu.com/ubuntu/

# Copy over files
%files
    main.py /
    requirements.txt /

%post
    # Create a directory to hold our scripts
    mkdir /app
    mv /main.py /app/
    mv /requirements.txt /app/

    # Make logs and results directories
    mkdir /logs
    mkdir /results

    # Install Python 3.5 and Pip
    apt-get install -y software-properties-common
    apt-add-repository universe
    apt-get update
    apt-get install -y python3-pip

    # Install Python requirements
    pip3 install -r /app/requirements.txt

Unlike Docker with Docker Hub, the only way to get Singularity images on Singularity Hub is to use automated builds from a Git repository on GitHub or Bitbucket. To set up automated builds with Singularity Hub, follow the instructions at github.com/singularityhub/singularityhub.github.io/wiki.

Saving anagram results

At this point, we have put code in Docker and Singularity containers, which, in my case, are located at

However, we still haven’t saved our results! Let’s use the /results/ directories we’ve created in our containers in our Python code:

#!/usr/bin/env python3
"""Find anagrams of words."""

from itertools import permutations
import json
import os
import sys
from typing import Set
from english_words import english_words_lower_alpha_set

RESULTS_DIR = '/results/'


def find_anagrams(word: str) -> Set[str]:
    """Find all one-word anagrams of a word.

    Arg:
        word: a string containing a word to find anagrams of.
    Returns:
        A set containing all anagrams of the word, in lowercase. This is
        guaranteed to at least contain the word itself, provided the
        word passed in is an English word.

    """
    # Find all permutations of the word
    perms = {''.join(p) for p in permutations(word.lower())}

    # Filter out all non-English permutations
    anagrams = {word for word in perms
                if word in english_words_lower_alpha_set}

    # Return the results
    return anagrams


if __name__ == '__main__':
    # Get the arguments as a raw JSON string
    raw_args = sys.argv[1]

    # Parse the raw JSON and return a dictionary of the parsed
    # arguments
    args = json.loads(raw_args)
    word = args['word']

    # Save anagrams
    filename = os.path.join(
        RESULTS_DIR,
        word + '.txt')

    with open(filename, 'w') as f:
        for result in find_anagrams(word):
            f.write(result + '\n')

Great! Before we register our task, we should add some basic logging so that debugging is easier down the line should we run into problems.

Logging the saltant way

Now let’s log some basic information to a file using Python’s logging library. All we need to do is log to a file in the directory that we’ve set up for our logs, /logs/. Let’s make use of the environment variables JOB_UUID, which is a variable containing the UUID of the task instance, passed down to every saltant-instantiated container and put all of the logs in logs/JOB_UUID-logs.txt.

The following code shows some toy debugging messages along with the logger setup:

#!/usr/bin/env python3
"""Find anagrams of words."""

from itertools import permutations
import json
import logging
import os
import sys
from typing import Set
from english_words import english_words_lower_alpha_set

LOGS_DIR = '/logs/'
RESULTS_DIR = '/results/'


def find_anagrams(word: str) -> Set[str]:
    """Find all one-word anagrams of a word.

    Arg:
        word: a string containing a word to find anagrams of.
    Returns:
        A set containing all anagrams of the word, in lowercase. This is
        guaranteed to at least contain the word itself, provided the
        word passed in is an English word.

    """
    # Find all permutations of the word
    logging.debug("Obtaining all permutations for \"%s\"", word)
    perms = {''.join(p) for p in permutations(word.lower())}

    # Filter out all non-English permutations
    logging.debug("Filtering all permutations for \"%s\"", word)
    anagrams = {word for word in perms
                if word in english_words_lower_alpha_set}

    # Return the results
    return anagrams


if __name__ == '__main__':
    # Set up the logger
    uuid = os.environ['JOB_UUID']
    logs_path = os.path.join(
        LOGS_DIR,
        uuid + '-logs.txt')

    logging.basicConfig(
        filename=logs_path,
        level=logging.DEBUG,
        format='%(levelname)s: %(message)s')

    # Get the arguments as a raw JSON string
    raw_args = sys.argv[1]

    # Parse the raw JSON and return a dictionary of the parsed
    # arguments
    args = json.loads(raw_args)
    word = args['word']

    # Save anagrams
    results_path = os.path.join(
        RESULTS_DIR,
        word + '.txt')

    with open(results_path, 'w') as f:
        for result in find_anagrams(word):
            f.write(result + '\n')

POST-ing the task to our saltant server

Now all of the coding we need to do is done. What’s left is to make a couple of requests to our saltant server, and we’re set.

For the purposes of this documentation let’s suppose our saltant server is set up up at https://www.anagraminc.org/saltant/ and that our API token for it is p0gch4mp101fy451do9uod1s1x9i4a.

To register our task we’ll hit the /containertasktypes/ API endpoint with information about our task (we’ll use the Singularity image here instead of the Docker image):

$ http POST "https://www.anagraminc.org/saltant/api/containertasktypes/" \
    name="anagram-generator" \
    description="Generates anagrams of a given word." \
    container_image="shub://saltant-org/saltant-working-example" \
    container_type="singularity" \
    script_path="/app/main.py" \
    logs_path="/logs/" \
    results_path="/results/" \
    required_arguments:='["word"]' \
    "Authorization: Token p0gch4mp101fy451do9uod1s1x9i4a"

and we should get back a response that looks like

HTTP/1.1 201 Created
Allow: GET, POST
Content-Length: 415
Content-Type: application/json
Date: Thu, 16 Aug 2018 01:27:08 GMT
Server: WSGIServer/0.2 CPython/3.6.5
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN

{
    "container_image": "shub://saltant-org/saltant-working-example",
    "container_type": "singularity",
    "datetime_created": "2018-08-16T01:27:08.766216Z",
    "description": "Generates anagrams of a given word.",
    "environment_variables": [],
    "id": 11,
    "logs_path": "/logs/",
    "name": "anagram-generator",
    "required_arguments": [
        "word"
    ],
    "required_arguments_default_values": {},
    "results_path": "/results/",
    "script_path": "/app/main.py",
    "user": "matt"
}

Running the task

Our boss has told us that we should run our task on his saltant task queue anagram-queue, which he has told us has ID 4. To do this, we make another request (this time to /containertaskinstances/), keeping in mind the ID of the task type we just created (which is 11):

$ http POST "https://www.anagraminc.org/saltant/api/containertaskinstances/" \
    arguments:='{"word": "binary"}' \
    task_type=11 \
    task_queue=4 \
    "Authorization: Token p0gch4mp101fy451do9uod1s1x9i4a"

to which we should get a response like

HTTP/1.1 201 Created
Allow: GET, POST
Content-Length: 223
Content-Type: application/json
Date: Thu, 16 Aug 2018 01:29:38 GMT
Server: WSGIServer/0.2 CPython/3.6.5
Vary: Accept, Cookie
X-Frame-Options: SAMEORIGIN

{
    "arguments": {
        "word": "binary"
    },
    "datetime_created": "2018-08-16T17:30:59.634727Z",
    "datetime_finished": null,
    "name": "",
    "state": "created",
    "task_queue": 4,
    "task_type": 11,
    "user": "matt",
    "uuid": "0b9715ff-df56-4331-9ba0-48597ae1f832"
}

The logs and results should now be available at the logs and results directories specified by the worker that ran the task on our boss’ task queue. The logs will be made available to the saltant front-end, but the results will stay on the worker’s machine.

Addendum: filesystem interfacing with Singularity

When we made a request to create our task type, as in

$ http POST "https://www.anagraminc.org/saltant/api/containertasktypes/" \
    name="anagram-generator" \
    description="Generates anagrams of a given word." \
    container_image="shub://saltant-org/saltant-working-example" \
    container_type="singularity" \
    script_path="/app/main.py" \
    logs_path="/logs/" \
    results_path="/results/" \
    required_arguments:='["word"]' \
    "Authorization: Token p0gch4mp101fy451do9uod1s1x9i4a"

The logs_path and results_path specify where in the container saltant should bind the worker’s WORKER_LOGS_DIRECTORY and WORKER_RESULTS_DIRECTORY. However, even somewhat recent-ish versions of Singularity have problems with custom bind points: they’re (so far as I’m aware) basically guaranteed to work with Singularity 2.4, but that was only released on 2017-10-02, and on earlier versions they might work (but no promises).

This may be a problem. Unless you can convince your system administrator to upgrade to Singularity 2.4, you’ll need a workaround.

One workaround works only if Singularity mounts the host’s filesystem to its images. In this case, the play is to, instead of making a request as bove, to not specify the logs_path and results_path and instead consume the worker’s WORKER_LOGS_DIRECTORY and WORKER_RESULTS_DIRECTORY environment variables and use those directly in your script.

So, for example, in main.py we might make the following changes:

#!/usr/bin/env python3
"""Find anagrams of words."""

from itertools import permutations
import json
import logging
import os
import sys
from typing import Set
from english_words import english_words_lower_alpha_set

LOGS_DIR = os.path.join(
    os.environ['WORKER_LOGS_DIRECTORY'],
    os.environ['JOB_UUID'])
RESULTS_DIR = os.path.join(
    os.environ['WORKER_RESULTS_DIRECTORY'],
    os.environ['JOB_UUID'])


def find_anagrams(word: str) -> Set[str]:
    """Find all one-word anagrams of a word.

    Arg:
        word: a string containing a word to find anagrams of.
    Returns:
        A set containing all anagrams of the word, in lowercase. This is
        guaranteed to at least contain the word itself, provided the
        word passed in is an English word.

    """
    # Find all permutations of the word
    logging.debug("Obtaining all permutations for \"%s\"", word)
    perms = {''.join(p) for p in permutations(word.lower())}

    # Filter out all non-English permutations
    logging.debug("Filtering all permutations for \"%s\"", word)
    anagrams = {word for word in perms
                if word in english_words_lower_alpha_set}

    # Return the results
    return anagrams


if __name__ == '__main__':
    # Set up the logger
    uuid = os.environ['JOB_UUID']
    logs_path = os.path.join(
        LOGS_DIR,
        uuid + '-logs.txt')

    logging.basicConfig(
        filename=logs_path,
        level=logging.DEBUG,
        format='%(levelname)s: %(message)s')

    # Get the arguments as a raw JSON string
    raw_args = sys.argv[1]

    # Parse the raw JSON and return a dictionary of the parsed
    # arguments
    args = json.loads(raw_args)
    word = args['word']

    # Save anagrams
    results_path = os.path.join(
        RESULTS_DIR,
        word + '.txt')

    with open(results_path, 'w') as f:
        for result in find_anagrams(word):
            f.write(result + '\n')

Note that, in the usual case when you specify non-null values for logs_path and results_path in the request, the logs and results are placed in a subdirectory that is named based on the task instance’s UUID, which we’ve emulated in the code above.

And then our request to create the task type would instead look like

$ http POST "https://www.anagraminc.org/saltant/api/tasktypes/" \
    name="anagram-generator" \
    description="Generates anagrams of a given word." \
    container_image="shub://saltant-org/saltant-working-example" \
    container_type="singularity" \
    script_path="/app/main.py" \
    environment_variables:='["WORKER_LOGS_DIRECTORY", "WORKER_RESULTS_DIRECTORY"]' \
    required_arguments:='["word"]' \
    "Authorization: Token p0gch4mp101fy451do9uod1s1x9i4a"