so

Literate programming version of “t”

Volume 3, Issue 41; 29 Nov 2019

My first attempt to use org-mode and tangle was not the overwhelming success that I had hoped for.

This weblog posting is an attempt to make something useful out of a currently failed experiment. It’s also an opportunity for someone with a clever idea about how to make a lambda out of an async function or otherwise work around the globals to send me a pointer. ☺

I deal with servers all over the world: at home, at work, at my ISP, at AWS, etc. Several times a day, I want to open up a shell window on one of those remote systems.

Suppose I open up a root shell on my local machine, a remote shell at work, and a remote shell on AWS. Now I want to delete some files, so I type rm -rf …. The jokes almost write themselves, don’t they?

I have a couple of large monitors and a high-resolution color display. Rather than using ssh in my ordinary shell window, I can configure different terminal windows, with different color schemes: matrix green on black, that’s my root window; MarkLogic red background, that’s my work window; an Amazon orange background, that’s my AWS window; etc.

When Docker enters the picture, it gets a little more complicated. What you want to do in the Docker case isn’t ssh but docker exec. Except, to do that you have to know the ID of the container and those change everytime you start a new one.

I quickly got tired of that, so I wrote dps a shell script to simplify my interations with docker. And then I realized that I could integrate that with my “start a terminal” script and t was born.

  • t → open a new terminal window on my local system.
  • t presentation → open a new terminal window on my local system with large fonts and high contrast.
  • t marklogic → open a terminal at work.
  • t aws → open a terminal at AWS.
  • t pihole → open a terminal on the RaspberryPi running Pi-hole.
  • t openwrt → open a terminal on my router.
  • t builder → open a terminal on the Docker containter “builder”.

And for good measure you can specify a command to run instead of just logging in.

  • t -c "/share/ml-compile b9_0" builder → build the b9_0 branch of MarkLogic server on the “builder” container

The t script was initially written in Perl and ran on both Linux and the Mac. I recently convert it to Python in order to take advantage of the Python APIs for ITerm2, my prefered MacOS terminal.

It starts off as a completely standard Python3 program.

#!/usr/bin/env python3

"""Creates a new iTerm window to execute a command. Interrogates
Docker and SSH configurations to determine a reasonable default command.
Profiles can be configured for different environments.
"""

import re
import os
import sys
import json
import argparse
import subprocess
from json import JSONDecodeError

The iterm2 and AppKit modules provide the ability to talk to iTerm2.

import iterm2
import AppKit

Putting HOME in a variable is just a convenience.

HOME = os.environ["HOME"]

The asynchronous function (runiterm, below) that actually makes the terminal needs to know what command to run and what profile to use, so these are globals.

ZSHCOMMAND = None
PROFILE = None


Global variables are frowned upon, but I can’t work out any other way to do it:

  • I can’t pass them as arguments to the function, because the function is part of the iTerm2 API.
  • I can’t construct the function as a lambda because (AFAICT), you can’t make an async lambda.

If there’s something I’ve overlooked, I’d love to hear it.

The runiniterm function itself is the one that actually interacts with the iTerm2 API. It gets an asynchronous connection to the running application and uses the API to make a new terminal with the desired profile.

async def runiterm(connection):
    """Runs ZSHCOMMAND in iTerm2. Shame about using the global."""
    app = await iterm2.async_get_app(connection)

    # Foreground the app
    await app.async_activate()

    if PROFILE is not None:
        all_profiles = await iterm2.PartialProfile.async_query(connection)
        for prof in all_profiles:
            if prof.name == PROFILE:
                await prof.async_make_default()

    if ZSHCOMMAND is None:
        zsh = "/usr/local/bin/zsh --login -i"
    else:
        zsh = '/usr/local/bin/zsh --login -i -c "%s"' % ZSHCOMMAND
    await iterm2.Window.async_create(connection, command=zsh)


There are (currently) four places where we can look to find a mapping between the specified terminal and the underlying commands.

To start with, there’s a configuration file ~/etc/t-profiles.json for just this purpose:

{
  "default": {},
  "root": {
    "profile": "Root",
    "command": "sudo su -"
  },
  "openwrt": {
    "profile": "Root",
    "command": "ssh openwrt"
  },
  "aws": {
    "profile": "AWS",
    "command": "ssh aws"
  },
  "dunsinane": {
    "profile": "Dunsinane",
    "command": "ssh dunsinane"
  },
  "presentation": {
    "profile": "Presentation"
  }
}

Each key in the top-level map is a possible terminal name. The profile identifes the iTerm2 profile to use. “Root” is matrix green-on-black, “Presentation” is large and high-contrast, etc.

The check_profiles function looks in the configuration file.

def check_profiles(term, command):
    """Search for the specified term in the global profiles."""
    name = None
    cmd = None

    try:
        with open("%s/etc/t-profiles.json" % HOME) as profile:
            profiles = json.load(profile)
    except FileNotFoundError:
        profiles = []
    except JSONDecodeError:
        profiles = []

    if term in profiles:
        if command is None and "command" in profiles[term]:
            cmd = profiles[term]["command"]
        if "profile" in profiles[term]:
            name = profiles[term]["profile"]

    return (name, cmd)


Another place we could look is among the running Docker containers. The check_docker function looks there.

def check_docker(term, command):
    """Search for the specified term in the running Docker containers.
    This function relies on 'dps' a wrapper script that simplifies
    interaction with 'docker ps' and friends"""
    name = None
    cmd = None

    # Maybe a running container?
    proc = subprocess.run(args=["dps", "id", term], check=False, capture_output=True)
    if proc.returncode != 0:
        print("Ambigous container id: %s" % term, file=sys.stderr)
        sys.exit(1)
    cid = proc.stdout.decode("utf-8").strip()
    if cid != "":
        name = "Docker"
        if command is None:
            cmd = "dps login %s" % cid
        else:
            cmd = "dps exec %s %s" % (cid, command)

    return (name, cmd)


My dps script (not shown) will search for containers by container name and image name. If we find a container name that matches, then we construct an appropriate command.

Another place to look is in my ~/.ssh/config file. You can use the SSH configuration file to simplify SSH. For example, consider the following fragment of my SSH config (fuzzed for privacy):

Host aws
  Hostname ec2-XX-XX-XX.us-XXX.compute.amazonaws.com
  User ec2-user
  IdentityFile ~/.ssh/AWS-EC2.pem

If I run ssh aws, this configuration will connect to the correct host, as the correct user, with the correct key. I no longer have to type out the long form.

The check_ssh function looks for a matching SSH configuration.

def check_ssh(term, command):
    """Search for the specified term in my ssh configuration."""
    name = None
    cmd = None

    # Maybe ssh?
    hostre = re.compile(r"^Host\s+%s" % term)
    with open("%s/.ssh/config" % HOME, "r") as config:
        for line in config:
            if hostre.match(line):
                name = "SSH"
                cmd = "ssh %s" % term
                if command is not None:
                    cmd = cmd + " " + command

    return (name, cmd)


Last, but not least, we can look in /etc/hosts. That’s what check_hosts does.

def check_hosts(term, command):
    """Search for the specified term in /etc/hosts."""
    name = None
    cmd = None

    hostre = re.compile(r"^\d+\.\d+\.\d+\.\d+\s+%s" % term)
    with open("/etc/hosts", "r") as hosts:
        for line in hosts:
            if hostre.match(line):
                name = "SSH"
                cmd = "ssh %s" % term
                if command is not None:
                    cmd = cmd + " " + command

    return (name, cmd)


There’s a utility function to get command line arguments. Mostly because Pylint barked at me for having a main function that was too long.

def parse_command_line():
    """Get the terminal selection and optional command from the command line."""
    parser = argparse.ArgumentParser()
    parser.add_argument("--command", "-c", action="store", help="Command to run")

Note that we specify default as the default profile. That’ll come up later.

parser.add_argument(
    "profile", action="store", nargs="?", default="default", help="Profile name"
)
args = vars(parser.parse_args())
term = args["profile"]
command = args["command"]
return (term, command)


Finally, we get to the main function.

def main():
    """Run command in an iTerm window with profile"""

I have to use the aforementioned globals, so I’m telling pylint explicitly not to ding me for them.

# pylint: disable=global-statement
global ZSHCOMMAND  # hack
global PROFILE  # hack

Next we get the initial terminal and command.

(term, command) = parse_command_line()

Then we look in the t profiles, docker containers, SSH, and hosts for that terminal. First one wins.

(name, cmd) = check_profiles(term, command)
if name is None:
    (name, cmd) = check_docker(term, command)
if name is None:
    (name, cmd) = check_ssh(term, command)
if name is None:
    (name, cmd) = check_hosts(term, command)

If we didn’t find the terminal, that’s ok if it’s the special profile default. In that case, we’ll just make a new terminal on the local host.

if name is None:
    if term == "default":
        pass
    else:
        print("Error: no such profile: %s" % term, file=sys.stderr)
        sys.exit(1)

If we got this far, we’ve figured out what to do. What to do is, move ourselves back to the home directory (for consistency) and use the iTerm2 API to spin up the shell.

os.chdir(HOME)

# Launch the app with iTerm
# pylint: disable=no-member
AppKit.NSWorkspace.sharedWorkspace().launchApplication_("iTerm2")

I don’t really understand how AppKit works. It’s true, as pylint warns me, that there appears to be no member named NSWorkspace, but it does, in fact, work, so I tell pylint to leave me alone.

PROFILE = name
ZSHCOMMAND = cmd
iterm2.run_until_complete(runiterm, True)


Finally, we end with the standard trick for allowing a module to be either run or imported. Not that it’s designed to be useful as a module, mind you.

if __name__ == "__main__":
    main()

That’s the script.

The pedantic among you may be itching to point out that what this post describes, and the way “tangle” in org-mode works, is not literate programming in the Knuthian sense as it offers no variable references, no reordering of blocks, and other “literate” features.

I understand those things. And if you’ve read this far, you will already have experienced the weakenesses that those features would mitigate: there are awkward forward references in several places, and generally the exposition would be improved by some reordering.

I’m still interested in this feature of org-mode, limitations nowithstanding.

Web mentions

Comments

IMHO your Jason is redundant. Use imported Python map of maps? Re avoiding global, could you wrap the call in a `python function, make it indirect that way?

—Posted by Dave pawson on 29 Nov 2019 @ 07:28 UTC #

I'm not sure what you mean by redundant; it's JSON because that's both easy to edit outside of Python and easy to import into Python.

Nested function declarations had not occurred to me! I will give that a try. Thank you, Dave!

I was thinking of reducing the number of Lamguages(data formats).. Bet python map access is easier / clearer than Jason.

—Posted by Dave Pawson on 29 Nov 2019 @ 10:02 UTC #

Oh, I see. No, I don't think so. JSON is being used here as a serialization format for plain old Python dictionaries and arrays. I'm blissfully unaware that it was ever in JSON at the point of use.

Please provide your name and email address. Your email address will not be displayed and I won’t spam you, I promise. Your name and a link to your web address, if you provide one, will be displayed.

Your name:

Your email:

Homepage:

Do you comprehend the words on this page? (Please demonstrate that you aren't a mindless, screen-scraping robot.)

What is ten times three?  (e.g. six plus two is 8)

Enter your comment in the box below. You may style your comment with the CommonMark flavor of Markdown.

All comments are moderated. I don’t promise to preserve all of your formatting and I reserve the right to remove comments for any reason.