Writing an external plugin

In this tutorial, we’ll write a plugin called local_fs that lets you navigate your local filesystem. Although the plugin isn’t practically useful, this guide illustrates the key concepts behind external plugin development.

An external plugin consists of a plugin script. Wash shells out to this script whenever it needs to invoke an entry’s supported action (like list), or if it needs to query something about the entry (like its metadata). In a more general sense, Wash shells out to the plugin script whenever it needs to invoke an entry’s supported method. The invocation’s stdout typically contains the method’s result, while its stderr typically contains any errors. To list local_fs’ children, for example, Wash invokes local_fs.sh list /local_fs, then parses the appropriate entry objects from stdout.

Note: Technically, Wash would invoke local_fs.sh list /local_fs '', where the third argument '' (empty string) represents local_fsstate. We are ignoring state in this tutorial, so the third argument will always be '' for all plugin script invocations.

To get local_fs working, the first thing we need to do is implement the init method. Wash invokes init on startup to retrieve information about the external plugin’s root. Start by copying the following code into a local_fs.sh file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/bin/bash

set -e

function to_json_array {
  local list="$1"

  echo -n "["
  local has_multiple_elem=""
  for elem in ${list}; do
    if [[ -n ${has_multiple_elem} ]]; then
      echo -n ","
    else
      has_multiple_elem="true"
    fi
    echo -n "${elem}"
  done
  echo -n "]"
}

function print_entry_json() {
  local name="$1"
  local methods="$2"

  local attributes_json="$3"
  if [[ -z "${attributes_json}" ]]; then
    attributes_json="{}"
  fi

  local partial_metadata_json="$4"
  if [[ -z "${partial_metadata_json}" ]]; then
    partial_metadata_json="{}"
  fi

  local methods_json=`to_json_array "${methods}"`
  echo -n "{\
\"name\":\"${name}\",\
\"methods\":${methods_json},\
\"attributes\":${attributes_json},\
\"partial_metadata\": ${partial_metadata_json}\
}"
}

# This code implements 'init'
method="$1"
if [[ "${method}" == "init" ]]; then
  if [[ -z "${HOME}" ]]; then
    # Notice how we're printing errors to stderr then exiting with an
    # exit code of 1. The latter tells Wash that 'init' failed.
    echo 'The $HOME environment variable is not set.' 1>&2
    exit 1
  fi
  print_entry_json "local_fs" '"list"'
  echo ""
  exit 0
fi

echo "No other entries have been implemented." 1>&2
exit 1

Note: Don’t forget to make local_fs.sh executable. chmod +x /path/to/local_fs.sh is one way of doing that.

Then invoke local_fs init. Your output should look something like this:

bash-3.2$ ./tutorials/local_fs.sh init
{"name":"local_fs","methods":["list"],"attributes":{},"partial_metadata":{}}

The printed JSON represents local_fs’ root. We see that the entry’s name is local_fs, that it implements list, and that it does not have any attributes or partial metadata.

Now that we’ve implemented init, let’s go ahead and see local_fs in action. Add the following to your ~/.puppetlabs/wash/wash.yaml file:

external-plugins:
  - script: '/Users/enis.inan/GitHub/wash/tutorials/local_fs.sh'

Start-up Wash and enter an ls. You should see local_fs included in the output.

bash-3.2$ wash
Welcome to Wash!
  Wash includes several built-in commands: wexec, find, list, meta, tail.
  See commands run with wash via 'whistory', and logs with 'whistory <id>'.
Try 'help'
wash . ❯ ls
aws/
docker/
gcp/
kubernetes/
local_fs/

We’re not done yet, so if you try to ls local_fs, you’ll get this error:

wash . ❯ ls local_fs
puppetlabs.wash/errored-action: The list action errored on /Users/enis.inan/Library/Caches/wash/mnt863144881/local_fs: script returned a non-zero exit code of 1
COMMAND: (PID 74196) /Users/enis.inan/GitHub/wash/local_fs.sh list /local_fs ''
STDERR:
No other entries have been implemented.

Now let’s implement the rest of the local_fs plugin. Replace the code in local_fs.sh with:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
#!/bin/bash

set -e

function to_json_array {
  local list="$1"

  echo -n "["
  local has_multiple_elem=""
  for elem in ${list}; do
    if [[ -n ${has_multiple_elem} ]]; then
      echo -n ","
    else
      has_multiple_elem="true"
    fi
    echo -n "${elem}"
  done
  echo -n "]"
}

function print_entry_json() {
  local name="$1"
  local methods="$2"

  local attributes_json="$3"
  if [[ -z "${attributes_json}" ]]; then
    attributes_json="{}"
  fi

  local partial_metadata_json="$4"
  if [[ -z "${partial_metadata_json}" ]]; then
    partial_metadata_json="{}"
  fi

  local methods_json=`to_json_array "${methods}"`
  echo -n "{\
\"name\":\"${name}\",\
\"methods\":${methods_json},\
\"attributes\":${attributes_json},\
\"partial_metadata\": ${partial_metadata_json}\
}"
}

function bsd_stat_cmd() {
  local file="$1"
  stat -f '%a %m %c %Up %z %d %i %u %g' "${file}"
}

function gnu_stat_cmd() {
  local file="$1"
  stat_output=`stat -c '%X %Y %Z %f %s %d %i %u %g' "${file}"`
  local atime mtime ctime mode size device inode_number uid gid
  read atime mtime ctime mode size device inode_number uid gid <<< $stat_output
  mode=$((16#${mode}))

  echo $atime $mtime $ctime $mode $size $device $inode_number $uid $gid
}

function list_dir() {
  local dir="$1"
  # This code should output something like the following:
  #   [
  #     <child_json>,
  #     <child_json>,
  #     ...
  #     <child_json>
  #   ]
  echo "["
  local has_multiple_elem=""
  # Ignore hidden files to avoid weird shell-specific errors. If we don't
  # do this, then the script generates some weird error messages on ZSH.
  for file in `find "${dir}" -mindepth 1 -maxdepth 1 -not -path '*/.*'`; do
    # Make sure to add the trailing comma.
    if [[ -n ${has_multiple_elem} ]]; then
      echo ","
    else
      has_multiple_elem="true"
    fi

    # Get the file attributes using stat. Note that STAT_CMD is platform-specific
    # so be sure to change it to whatever's supported by your OS' stat command. The
    # subsequent comments provide some more guidance. In general, `STAT_CMD <file>`
    # should output the following info: 
    #   <atime> <mtime> <ctime> <mode> <size> <device> <inode_number> <uid> <gid>
    #
    # where the time attributes are in UNIX seconds, and all the other attributes
    # are decimal numbers.
    local STAT_CMD=""

    # Uncomment this if you're on OSX
    #local STAT_CMD="bsd_stat_cmd"

    # Uncomment this if you're on Linux
    #local STAT_CMD="gnu_stat_cmd"

    # Otherwise, you'll need to set STAT_CMD.
    #local STAT_CMD=""

    if [[ -z "${STAT_CMD}" ]]; then
      echo "Did not set STAT_CMD." 1>&2
      exit 1
    fi

    # Use stat to get the file's attributes.
    local stat_output=`${STAT_CMD} "${file}"`
    local atime mtime ctime mode size device inode_number uid gid
    read atime mtime ctime mode size device inode_number uid gid <<< $stat_output

    local methods
    if test -d "${file}"; then
      methods='"list"'
    else
      methods='"read"'
    fi

    local attributes_json="{\
\"atime\":${atime},\
\"mtime\":${mtime},\
\"ctime\":${ctime},\
\"size\":${size}\
}"

    local partial_metadata_json="{\
\"atime\":${atime},\
\"mtime\":${mtime},\
\"ctime\":${ctime},\
\"mode\":${mode},\
\"size\":${size},\
\"device\":${device},\
\"inodeNumber\":${inode_number},\
\"uid\":${uid},\
\"gid\":${gid}\
}"

    print_entry_json `basename "${file}"` "${methods}" "${attributes_json}" "${partial_metadata_json}"
  done
  echo ""
  echo -n "]"
}

# This code implements 'init'
method="$1"
if [[ "${method}" == "init" ]]; then
  if [[ -z "${HOME}" ]]; then
    # Notice how we're printing errors to stderr then exiting with an
    # exit code of 1. The latter tells Wash that 'init' failed.
    echo 'The $HOME environment variable is not set.' 1>&2
    exit 1
  fi
  print_entry_json "local_fs" '"list"'
  echo ""
  exit 0
fi

# This code implements all the other entries. Note that
# Wash only invokes supported methods. Thus, we don't
# have to worry about cases like reading a directory or
# listing a file's children.
path="$2"
path=`echo "${path}" | sed "s:^/local_fs:${HOME}:g"`
case "${method}" in
"list")
  list_dir "${path}"
  exit 0
;;
"read")
  cat "${path}"
  exit 0
;;
esac

Note: Don’t forget to set the STAT_CMD variable on line 88.

Now you can ls local_fs:

wash . ❯ ls local_fs/
Applications/
Desktop/
Documents/
Downloads/
GitHub/
Library/
Movies/
Music/
Pictures/
Public/
go/

Compare your output with ls $HOME (ignoring the hidden files).

Remember, when you use ls local_fs, which invokes the list action on the local_fs entry, Wash invokes local_fs.sh list /local_fs and parses its output. Let’s see what happens when we invoke the script ourselves:

bash-3.2$ ./tutorials/local_fs.sh list /local_fs
[
{"name":"Music","methods":["list"],"attributes":{"atime":1576614404,"mtime":1575331025,"ctime":1575331025,"size":128},"partial_metadata": {"atime":1576614404,"mtime":1575331025,"ctime":1575331025,"mode":16832,"size":128,"device":16777221,"inodeNumber":12885648174,"uid":501,"gid":20}},
{"name":"go","methods":["list"],"attributes":{"atime":1576614404,"mtime":1576557876,"ctime":1576557876,"size":160},"partial_metadata": {"atime":1576614404,"mtime":1576557876,"ctime":1576557876,"mode":16877,"size":160,"device":16777221,"inodeNumber":12886708625,"uid":501,"gid":20}},
{"name":"Pictures","methods":["list"],"attributes":{"atime":1578617514,"mtime":1577074362,"ctime":1577074362,"size":128},"partial_metadata": {"atime":1578617514,"mtime":1577074362,"ctime":1577074362,"mode":16832,"size":128,"device":16777221,"inodeNumber":12885648177,"uid":501,"gid":20}},
{"name":"Desktop","methods":["list"],"attributes":{"atime":1578514087,"mtime":1577778220,"ctime":1577778220,"size":480},"partial_metadata": {"atime":1578514087,"mtime":1577778220,"ctime":1577778220,"mode":16832,"size":480,"device":16777221,"inodeNumber":12885648179,"uid":501,"gid":20}},
{"name":"Library","methods":["list"],"attributes":{"atime":1577838807,"mtime":1578958647,"ctime":1578958647,"size":1984},"partial_metadata": {"atime":1577838807,"mtime":1578958647,"ctime":1578958647,"mode":16832,"size":1984,"device":16777221,"inodeNumber":12885648155,"uid":501,"gid":20}},
{"name":"Public","methods":["list"],"attributes":{"atime":1575330350,"mtime":1575330349,"ctime":1575330350,"size":128},"partial_metadata": {"atime":1575330350,"mtime":1575330349,"ctime":1575330350,"mode":16877,"size":128,"device":16777221,"inodeNumber":12885648228,"uid":501,"gid":20}},
{"name":"GitHub","methods":["list"],"attributes":{"atime":1578973413,"mtime":1576614164,"ctime":1576614164,"size":512},"partial_metadata": {"atime":1578973413,"mtime":1576614164,"ctime":1576614164,"mode":16877,"size":512,"device":16777221,"inodeNumber":12886683114,"uid":501,"gid":20}},
{"name":"Movies","methods":["list"],"attributes":{"atime":1575330350,"mtime":1575330349,"ctime":1575330350,"size":96},"partial_metadata": {"atime":1575330350,"mtime":1575330349,"ctime":1575330350,"mode":16832,"size":96,"device":16777221,"inodeNumber":12885648232,"uid":501,"gid":20}},
{"name":"Applications","methods":["list"],"attributes":{"atime":1578514067,"mtime":1575331450,"ctime":1575331450,"size":96},"partial_metadata": {"atime":1578514067,"mtime":1575331450,"ctime":1575331450,"mode":16832,"size":96,"device":16777221,"inodeNumber":12885796512,"uid":501,"gid":20}},
{"name":"Documents","methods":["list"],"attributes":{"atime":1576522412,"mtime":1575325014,"ctime":1575337685,"size":1440},"partial_metadata": {"atime":1576522412,"mtime":1575325014,"ctime":1575337685,"mode":16832,"size":1440,"device":16777221,"inodeNumber":12885817316,"uid":501,"gid":20}},
{"name":"Downloads","methods":["list"],"attributes":{"atime":1578974744,"mtime":1578511137,"ctime":1578511137,"size":608},"partial_metadata": {"atime":1578974744,"mtime":1578511137,"ctime":1578511137,"mode":16832,"size":608,"device":16777221,"inodeNumber":12885648169,"uid":501,"gid":20}}
]

Each JSON object corresponds to a child entry. For example, the Applications directory’s JSON object is:

{
  "name": "Applications",
  "methods": ["list"],
  "attributes": {
    "atime": 1578514067,
    "mtime": 1575331450,
    "ctime": 1575331450,
    "size": 96
  },
  "partial_metadata": {
    "atime": 1578514067,
    "mtime": 1575331450,
    "ctime": 1575331450,
    "mode": 16832,
    "size": 96,
    "device": 16777221,
    "inodeNumber": 12885796512,
    "uid": 501,
    "gid": 20
  }
}

Notice that list’s output also included the children’s attributes. We can use winfo to check them out.

wash . ❯ winfo local_fs/Applications
Name: Applications
CName: Applications
Actions:
- list
Attributes:
  atime: 2020-01-08T12:07:47-08:00
  ctime: 2019-12-02T16:04:10-08:00
  mtime: 2019-12-02T16:04:10-08:00
  size: 96

Note: The mode attribute doesn’t work on Mac OS so we are omitting it for now. However, we’re still including it as metadata.

We can also use meta to check out each child’s metadata.

wash . ❯ meta local_fs/Applications
atime: 1578514067
ctime: 1575331450
device: 16777221
gid: 20
inodeNumber: 12885796512
mode: 16832
mtime: 1575331450
size: 96
uid: 501

Notice that the output matches the partial metadata. That’s because the partial metadata completely describes local_fs’ children.

Remember: The partial metadata represents the raw response of a “bulk” fetch. For local_fs, the response would be stat’s output.

Congratulations! You’ve created your own Wash plugin to emulate some common file and directory filtering via the local_fs plugin and Wash find. The Wash external plugin interface, together with the attribute and metadata abstraction, make it possible for you to filter anything on almost anything! We can’t wait to see what you do with Wash. Share your creations on the Wash Slack channel.

Exercises

  1. Try cat’ing a file in the local_fs plugin. What’s the underlying plugin script invocation? Hint: It’s similar to list.

    Answerlocal_fs.sh read <path_to_file>

  2. Implement the stream action for files. Hint: Take a look at lines 109-114, and 155-170. Your implementation should be a wrapper to tail -f. Also, don’t forget to print the header!

    Answer On line 113, add stream to the list of supported methods. Then add the following case to the case statement in line 155:
        "stream")
          echo "200"
          tail -f "${path}"
          exit 0
        ;;

  3. How would you

    1. Find all local_fs entries that were modified within the last hour? Hint: find local_fs -mtime -2h gives you all local_fs entries that were modified within the last two hours.

      Answerfind local_fs -mtime -1h

    2. Find all local_fs entries that are owned by the user with UID 10? Hint: find local_fs -meta '.gid' 20 give you all local_fs entries with GID 20.

      Answerfind local_fs -meta '.uid' 10

Related Links

Next steps

That’s the end of the Extending Wash series! Click here to go back to the tutorials page.