From 2edfa68ef0d91569b78325cd32893655166211ae Mon Sep 17 00:00:00 2001
From: Michal Cichra <michal@cichra.cz>
Date: Wed, 25 Oct 2017 16:53:34 +0200
Subject: [PATCH] templating prototype

---
 Makefile                              |   4 +-
 apicast/Roverfile.lock                |   4 +
 apicast/apicast-0.1-0.rockspec        |   1 +
 apicast/bin/apicast                   | 137 ++------------------
 apicast/bin/cli                       |  11 ++
 apicast/conf/nginx.conf.liquid        |  92 +++++++++++++
 apicast/config/development.lua        |  12 ++
 apicast/config/production.lua         |   5 +
 apicast/http.d/ssl.conf               |   4 +-
 apicast/src/apicast/cli.lua           |  54 ++++++++
 apicast/src/apicast/cli/start.lua     | 179 ++++++++++++++++++++++++++
 apicast/src/apicast/configuration.lua |  55 ++++++++
 apicast/src/apicast/template.lua      |  99 ++++++++++++++
 bin/cli                               |   1 +
 14 files changed, 529 insertions(+), 129 deletions(-)
 create mode 100755 apicast/bin/cli
 create mode 100644 apicast/conf/nginx.conf.liquid
 create mode 100644 apicast/config/development.lua
 create mode 100644 apicast/config/production.lua
 create mode 100644 apicast/src/apicast/cli.lua
 create mode 100644 apicast/src/apicast/cli/start.lua
 create mode 100644 apicast/src/apicast/configuration.lua
 create mode 100644 apicast/src/apicast/template.lua
 create mode 120000 bin/cli

diff --git a/Makefile b/Makefile
index f699f5c28..94660696a 100644
--- a/Makefile
+++ b/Makefile
@@ -83,9 +83,9 @@ test-builder-image: export IMAGE_NAME = apicast-test
 test-builder-image: builder-image clean-containers ## Smoke test the builder image. Pass any docker image in IMAGE_NAME parameter.
 	$(DOCKER_COMPOSE) --version
 	@echo -e $(SEPARATOR)
-	$(DOCKER_COMPOSE) run --rm --user 100001 gateway openresty -c /opt/app-root/src/conf/nginx.conf -g 'error_log stderr info; pid /tmp/nginx.pid;' -t
+	$(DOCKER_COMPOSE) run --rm --user 100001 gateway bin/apicast --test
 	@echo -e $(SEPARATOR)
-	$(DOCKER_COMPOSE) run --rm --user 100001 gateway openresty -c /opt/app-root/src/conf/nginx.conf -g 'error_log stderr info; pid /tmp/nginx.pid;'
+	$(DOCKER_COMPOSE) run --rm --user 100001 gateway bin/apicast --daemon
 	@echo -e $(SEPARATOR)
 	$(DOCKER_COMPOSE) run --rm test bash -c 'for i in {1..5}; do curl --fail http://gateway:8090/status/live && break || sleep 1; done'
 	$(DOCKER_COMPOSE) logs gateway
diff --git a/apicast/Roverfile.lock b/apicast/Roverfile.lock
index e85290c4b..43527631f 100644
--- a/apicast/Roverfile.lock
+++ b/apicast/Roverfile.lock
@@ -1,8 +1,12 @@
+ansicolors 1.0.2-3
+argparse 0.5.0-1
 busted 2.0.rc12-1
 dkjson 2.5-2
 inspect 3.1.0-1
 ldoc 1.4.6-2
+liquid scm-1
 lua-resty-env 0.4.0-1
+lua-resty-execvp 0.1.0-1
 lua-resty-http 0.10-0
 lua-resty-iputils 0.3.0-1
 lua-resty-jwt 0.1.10-1
diff --git a/apicast/apicast-0.1-0.rockspec b/apicast/apicast-0.1-0.rockspec
index 75cc81a9d..b938024fd 100644
--- a/apicast/apicast-0.1-0.rockspec
+++ b/apicast/apicast-0.1-0.rockspec
@@ -8,6 +8,7 @@ dependencies = {
   'lua-resty-jwt',
   'lua-resty-url',
   'lua-resty-env',
+  'lua-resty-execvp',
 }
 build = {
    type = "builtin",
diff --git a/apicast/bin/apicast b/apicast/bin/apicast
index f454e49bd..c087189ba 100755
--- a/apicast/bin/apicast
+++ b/apicast/bin/apicast
@@ -1,130 +1,17 @@
-#!/bin/bash
+#!/usr/bin/env perl
+use strict;
+use warnings FATAL => 'all';
 
-set -euo pipefail
-IFS=$'\n\t'
+use File::Basename;
+use Cwd qw(abs_path);
 
-script=${BASH_SOURCE[0]}
-if (readlink -f "${script}" > /dev/null 2>&1); then
-  path=$(readlink -f "${script}")
-elif (readlink "${script}" > /dev/null 2>&1); then
-  path="$(dirname "${script}")/$(readlink "${script}")"
-else
-  path="${script}"
-fi
+my $apicast = $ENV{APICAST_DIR} || abs_path(dirname(abs_path(__FILE__)) . '/..');
+my $bindir = $apicast . '/bin';
+my $lua_path = $ENV{LUA_PATH} || ';';
 
-bin_dir=$(dirname "${path}")
-apicast_dir=${APICAST_DIR:-"$( cd "${bin_dir}/.." && pwd )"}
+chdir $apicast;
 
-pick_openresty() {
-  for cmd in "$@"
-  do
-    if (${cmd} -V > /dev/null 2>&1); then
-      echo "${cmd}"
-      exit 0
-    fi
-  done
+$ENV{LUA_PATH} = "$apicast/src/?.lua;${lua_path}";
 
-  (>&2 echo "ERROR: Could not find openresty executable in your PATH.")
-  (>&2 echo "Make sure you have one of: $(printf "%s " "$@")")
-  exit 1
-}
-
-openresty_binary=${APICAST_OPENRESTY_BINARY:-$(pick_openresty openresty-debug openresty nginx)}
-log_level=${APICAST_LOG_LEVEL:-warn}
-log_file=${APICAST_LOG_FILE:-stderr}
-log_levels=(emerg alert crit error warn notice info debug)
-((max_log_level=${#log_levels[@]}-1))
-
-for ((i=0; i < ${#log_levels[@]}; i++)); do
-	ll=${log_levels[i]}
-	declare -r "log_level_${ll}=$i"
-done
-
-log="log_level_${log_level}"
-log_level="${!log}"
-
-daemon=off
-worker_processes=${APICAST_WORKERS:-1}
-
-usage () {
-	cat <<-USAGE
-	Usage $0
-	  -h Show this help
-	  -c <file> Path to custom config file (JSON).
-	  -d Daemonize
-	  -v Increase verbosity (can be repeated)
-	  -i Cache configuration for N seconds. Using 0 will reload on every request (not for production).
-	  -w <workers> Number of worker processes to start.
-	  -m <on|off> Whether to start worker processes. Only for development.
-	  -s <signal> Send signal to a master process: stop, quit, reopen, reload
-	  -p <pid> Path to the PID file.
-	  -b Load configuration on boot.
-	  -e Deployment environment. Can be staging or production.
-USAGE
-}
-
-main=("")
-args=("")
-
-while getopts ":dc:hvbqi:rw:m:s:p:e:" opt; do
-  case "${opt}" in
-    d)
-      daemon="on"
-      ;;
-    c)
-      export APICAST_CONFIGURATION="$OPTARG"
-      ;;
-    b)
-      export APICAST_CONFIGURATION_LOADER="boot"
-      ;;
-    v)
-	    log_level=$((log_level == max_log_level ? max_log_level : log_level+1))
-      ;;
-    q)
-	    log_level=$((log_level == 0 ? 0 : log_level-1))
-      ;;
-    i)
-      export APICAST_CONFIGURATION_CACHE="${OPTARG}"
-      ;;
-    e)
-      export THREESCALE_DEPLOYMENT_ENV="${OPTARG}"
-    ;;
-    w)
-      worker_processes=${OPTARG}
-    ;;
-    m)
-      main+=("master_process ${OPTARG};")
-    ;;
-    p)
-      main+=("pid ${PWD}/${OPTARG};")
-    ;;
-    s)
-      args+=("-s" "${OPTARG}")
-    ;;
-    h)
-      usage
-      exit 0
-      ;;
-    \?)
-      echo "Invalid option: -${OPTARG}" >&2
-      echo
-      usage
-      exit 1
-      ;;
-  esac
-done
-
-export THREESCALE_DEPLOYMENT_ENV=${THREESCALE_DEPLOYMENT_ENV-production}
-
-main+=("daemon ${daemon};")
-main+=("worker_processes ${worker_processes};")
-main+=$(printenv | awk '$1 ~ /^(APICAST|THREESCALE)_/ {split($0,env,"="); print "env", env[1] ";"}')
-
-function join_by { local IFS="$1"; shift; echo "$*"; }
-args=$(join_by '' "${args[@]}")
-main=$(join_by '' "${main[@]}")
-
-cd "${apicast_dir}"
-
-# shellcheck disable=SC2086
-exec "${openresty_binary}" -c "${apicast_dir}/conf/nginx.conf"  ${args} -g "${main} error_log ${log_file} ${log_levels[log_level]};"
+exec '/usr/bin/env', 'resty',
+    "$bindir/cli", @ARGV;
diff --git a/apicast/bin/cli b/apicast/bin/cli
new file mode 100755
index 000000000..330e08a59
--- /dev/null
+++ b/apicast/bin/cli
@@ -0,0 +1,11 @@
+#!/usr/bin/env resty
+
+local ok, setup = pcall(require, 'rover.setup')
+
+if ok then
+    setup()
+else
+    package.path = './src/?.lua;' .. package.path
+end
+
+require('apicast.cli')(arg)
diff --git a/apicast/conf/nginx.conf.liquid b/apicast/conf/nginx.conf.liquid
new file mode 100644
index 000000000..797fbe9d9
--- /dev/null
+++ b/apicast/conf/nginx.conf.liquid
@@ -0,0 +1,92 @@
+env REDIS_HOST;
+env REDIS_PORT;
+env REDIS_URL;
+env RESOLVER;
+env BACKEND_ENDPOINT_OVERRIDE;
+env OPENSSL_VERIFY;
+
+{% for env in env -%}
+  {%- if env.name | starts_with: 'APICAST_', 'THREESCALE_' %}
+    env {{ env.name }};
+  {%- endif -%}
+{%- endfor %}
+
+daemon {{ daemon | default: 'off' }};
+master_process {{ master_process | default: 'on' }};
+worker_processes {{ worker_processes | default: 'auto' }};
+
+{% for file in "main.d/*.conf" | filesystem %}
+  {% include file %}
+{% endfor %}
+
+error_log /dev/null emerg;
+
+events {
+  worker_connections  16192;
+  multi_accept        on;
+}
+
+http {
+  sendfile           on;
+  tcp_nopush         on;
+  tcp_nodelay        on;
+
+  # Enabling the Lua code cache is strongly encouraged for production use
+  # Disabling it should only be done for testing and development purposes
+  lua_code_cache {{  lua_code_cache | default: 'on' }};
+
+  server_names_hash_bucket_size 128;
+
+  log_format time '[$time_local] $host:$server_port $remote_addr:$remote_port "$request" $status $body_bytes_sent ($request_time) $post_action_impact';
+  access_log off;
+
+  lua_package_path ";;{{prefix}}/?.lua;{{prefix}}/src/?.lua";
+
+  {% for file in "http.d/*.conf" | filesystem %}
+    {% include file %}
+  {% endfor %}
+
+  server {
+    listen 8090;
+
+    server_name _;
+
+    {% include "conf.d/management.conf" %}
+  }
+
+  server {
+    listen 8081;
+
+    server_name backend;
+
+    {% include "conf.d/backend.conf" %}
+  }
+
+  server {
+    listen 8081 default_server;
+
+    server_name echo _;
+
+    {% include "conf.d/echo.conf" %}
+  }
+
+  server {
+    access_log /dev/stdout time;
+
+    listen 8080;
+
+    server_name _;
+    underscores_in_headers on;
+
+    {% include "http.d/ssl.conf" %}
+
+    {% for file in "apicast.d/*.conf" | filesystem %}
+      {% include file %}
+    {% endfor %}
+    {% include "conf.d/apicast.conf" %}
+  }
+
+  {% for file in "sites.d/*.conf" | filesystem %}
+    {% include file %}
+  {% endfor %}
+}
diff --git a/apicast/config/development.lua b/apicast/config/development.lua
new file mode 100644
index 000000000..ee8a7af1a
--- /dev/null
+++ b/apicast/config/development.lua
@@ -0,0 +1,12 @@
+local lr_path, lr_cpath, lr_bin = require('luarocks.cfg').package_paths()
+
+return {
+    worker_processes = '1',
+    master_process = 'off',
+    lua_code_cache = 'off',
+    lua_path = "./src/?.lua;./src/?/init.lua;"..lr_path,
+    lua_cpath = lr_cpath,
+    env = {
+        PATH = lr_bin -- this probably also needs to use the previous value
+    }
+}
diff --git a/apicast/config/production.lua b/apicast/config/production.lua
new file mode 100644
index 000000000..297f7c6bf
--- /dev/null
+++ b/apicast/config/production.lua
@@ -0,0 +1,5 @@
+return {
+    worker_processes = 'auto',
+    master_process = 'on',
+    lua_code_cache = 'on',
+}
diff --git a/apicast/http.d/ssl.conf b/apicast/http.d/ssl.conf
index 5e97f5f6f..77e6d2abc 100644
--- a/apicast/http.d/ssl.conf
+++ b/apicast/http.d/ssl.conf
@@ -5,10 +5,10 @@
 ## it really hard to have working cross platform configuration.
 #
 lua_ssl_verify_depth 5;
-lua_ssl_trusted_certificate ca-bundle.crt;
+lua_ssl_trusted_certificate "{{ ca_bundle | default: 'ca-bundle.crt' }}";
 
 proxy_ssl_server_name on;
 proxy_ssl_name $http_host;
 proxy_ssl_verify_depth 5;
 
-proxy_ssl_trusted_certificate ca-bundle.crt;
+proxy_ssl_trusted_certificate "{{ ca_bundle | default: 'ca-bundle.crt' }}";
diff --git a/apicast/src/apicast/cli.lua b/apicast/src/apicast/cli.lua
new file mode 100644
index 000000000..4a73dd31a
--- /dev/null
+++ b/apicast/src/apicast/cli.lua
@@ -0,0 +1,54 @@
+local command_target = '_cmd'
+local parser = require('argparse')() {
+    name = "APIcast",
+    description = "APIcast - 3scale API Management Platform Gateway."
+}
+:command_target(command_target)
+:require_command(false)
+:handle_options(false)
+
+local _M = { }
+
+local mt = {}
+
+local function load_commands(commands, argparse)
+    for i=1, #commands do
+        commands[commands[i]] = require('apicast.cli.' .. commands[i]):new(argparse)
+    end
+    return commands
+end
+
+_M.commands = load_commands({ 'start' }, parser)
+
+function mt.__call(self, arg)
+    -- now we parse the options like usual:
+    local ok, ret = self.parse(arg)
+
+    if not ok and ret then
+        local err = ret
+        table.insert(arg, 1, 'start')
+        ok, ret = self.parse(arg)
+        if not ok then
+            ret = err
+            table.remove(arg, 1)
+       end
+    end
+
+    local cmd = ok and ret[command_target]
+
+    if ok and cmd then
+        self.commands[cmd](ret)
+    elseif ret and not next(ret) then
+        local start = self.commands.start
+        start(start:parse(arg))
+    else
+        print(ret)
+        os.exit(1)
+    end
+end
+
+function _M.parse(arg)
+    return parser:pparse(arg)
+end
+
+return setmetatable(_M, mt)
diff --git a/apicast/src/apicast/cli/start.lua b/apicast/src/apicast/cli/start.lua
new file mode 100644
index 000000000..e46d8f915
--- /dev/null
+++ b/apicast/src/apicast/cli/start.lua
@@ -0,0 +1,179 @@
+local setmetatable = setmetatable
+local pairs = pairs
+local min = math.min
+local max = math.max
+local insert = table.insert
+local concat = table.concat
+
+local exec = require('resty.execvp')
+local resty_env = require('resty.env')
+
+local colors = require('ansicolors')
+local Template = require('apicast.template')
+local configuration = require('apicast.configuration')
+
+local pl = {
+    path = require('pl.path'),
+    file = require('pl.file'),
+    dir = require('pl.dir'),
+}
+
+local _M = {
+    openresty = { 'openresty-debug', 'openresty', 'nginx' },
+    log_levels = { 'emerg', 'alert', 'crit', 'error', 'warn', 'notice', 'info', 'debug' },
+    log_level = 5, -- warn
+    log_file = 'stderr',
+}
+
+local mt = { __index = _M }
+
+local function pick_openesty(candidates)
+    for i=1, #candidates do
+        local ok = os.execute(('%s -V 2>/dev/null'):format(candidates[i]))
+
+        if ok then
+            return candidates[i]
+        end
+    end
+
+    error("could not find openresty executable")
+end
+
+local function update_env(env)
+    for name, value in pairs(env) do
+        resty_env.set(name, value)
+    end
+end
+
+local function nginx_config(context, dir, path, env)
+    update_env(env)
+
+    local template = Template:new(context, dir, true)
+    local tmp = pl.path.tmpname()
+    pl.file.write(tmp, template:render(path))
+    return tmp
+end
+
+local function create_prefix()
+    local tmp = os.tmpname()
+
+    assert(pl.file.delete(tmp))
+    assert(pl.dir.makepath(tmp .. '/logs'))
+
+    return tmp
+end
+
+local function get_log_level(self, options)
+    local log_level = options.log_level
+    local n = #(self.log_levels)
+    for i=1, n do
+        if self.log_levels[i] == log_level then
+            i = i + options.verbose - options.quiet
+            log_level = self.log_levels[max(min(i, n), 1)]
+            break
+        end
+    end
+    return log_level
+end
+
+function mt:__call(options)
+    local openresty = resty_env.get('APICAST_OPENRESTY_BINARY') or pick_openesty(self.openresty)
+    local dir = resty_env.get('APICAST_DIR') or pl.path.abspath('.')
+    local config = configuration.new(dir)
+    local path = options.template
+    local environment = options.dev and 'development' or options.environment
+    local context = config:load(environment)
+    local env = {
+        APICAST_CONFIGURATION = options.configuration,
+        APICAST_CONFIGURATION_LOADER = options.boot and 'boot' or 'lazy',
+        APICAST_CONFIGURATION_CACHE = options.cache,
+        THREESCALE_DEPLOYMENT_ENV = environment,
+    }
+
+    context.worker_processes = options.workers or context.worker_processes
+
+    if options.daemon then
+        context.daemon = 'on'
+    end
+
+    context.prefix = dir
+    context.ca_bundle = pl.path.abspath(context.ca_bundle or pl.path.join(dir, 'conf', 'ca-bundle.crt'))
+
+    local nginx = nginx_config(context, dir, path, env)
+
+    local log_level = get_log_level(self, options)
+    local log_file = options.log_file or self.log_file
+    local global = {
+        ('error_log %s %s'):format(log_file, log_level)
+    }
+    local prefix = create_prefix()
+
+    local cmd = { '-c', nginx, '-g', concat(global, '; ') .. ';', '-p', prefix }
+
+    if options.test then
+        insert(cmd, options.debug and '-T' or '-t')
+    end
+
+    return exec(openresty, cmd, env)
+end
+
+local function configure(cmd)
+    cmd:usage(colors("%{bright red}Usage: apicast-cli start [OPTIONS]"))
+    cmd:option("--template", "Nginx config template.", 'conf/nginx.conf.liquid')
+
+    cmd:mutex(
+        cmd:option('-e --environment', "Deployment to start.", resty_env.get('THREESCALE_DEPLOYMENT_ENV')),
+        cmd:flag('--dev', 'Start in development environment')
+    )
+
+    cmd:flag("-t --test", "Test the nginx config")
+    cmd:flag("--debug", "Debug mode. Prints more information.")
+    cmd:option("-c --configuration",
+        "Path to custom config file (JSON)",
+        resty_env.get('APICAST_CONFIGURATION'))
+    cmd:flag("-d --daemon", "Daemonize.")
+    cmd:option("-w --workers",
+        "Number of worker processes to start.",
+        resty_env.get('APICAST_WORKERS') or 1)
+    cmd:option("-p --pid", "Path to the PID file.")
+    cmd:mutex(
+        cmd:flag('-b --boot',
+            "Load configuration on boot.",
+            resty_env.get('APICAST_CONFIGURATION_LOADER') == 'boot'),
+        cmd:flag('-l --lazy',
+            "Load configuration on demand.",
+            resty_env.get('APICAST_CONFIGURATION_LOADER') == 'lazy')
+    )
+    cmd:option("-i --refresh-interval",
+        "Cache configuration for N seconds. Using 0 will reload on every request (not for production).",
+        resty_env.get('APICAST_CONFIGURATION_CACHE'))
+
+    cmd:mutex(
+        cmd:flag('-v --verbose',
+            "Increase logging verbosity (can be repeated).")
+        :count(("0-%s"):format(#(_M.log_levels) - _M.log_level)),
+        cmd:flag('-q --quiet', "Decrease logging verbosity.")
+        :count(("0-%s"):format(_M.log_level - 1))
+    )
+    cmd:option('--log-level', 'Set log level', resty_env.get('APICAST_LOG_LEVEL') or 'warn')
+    cmd:option('--log-file', 'Set log file', resty_env.get('APICAST_LOG_FILE') or 'stderr')
+
+    cmd:epilog(colors([[
+      Example: %{bright red} apicast start --dev %{reset}
+        This will start APIcast in development mode.]]))
+
+    return cmd
+end
+
+function _M:new(parser)
+    local cmd = configure(parser:command('start', 'Start APIcast'))
+
+    return setmetatable({ parser = parser, cmd = cmd }, mt)
+end
+
+function _M:parse(arg)
+    return self.cmd:parse(arg)
+end
+
+
+return setmetatable(_M, mt)
diff --git a/apicast/src/apicast/configuration.lua b/apicast/src/apicast/configuration.lua
new file mode 100644
index 000000000..d1ce92b81
--- /dev/null
+++ b/apicast/src/apicast/configuration.lua
@@ -0,0 +1,55 @@
+local pl_path = require('pl.path')
+local resty_env = require('resty.env')
+local setmetatable = setmetatable
+local loadfile = loadfile
+local pcall = pcall
+local require = require
+local assert = assert
+local error = error
+local print = print
+local pairs = pairs
+
+local _M = {
+    default_environment = 'production',
+    default_config = {
+        ca_bundle = resty_env.get('SSL_CERT_FILE')
+    }
+}
+
+local mt = { __index = _M }
+
+function _M.new(root)
+    return setmetatable({ root = root }, mt)
+end
+
+function _M:load(env)
+    local environment = env or self.default_environment
+    local root = self.root
+    local name = ("%s.lua"):format(environment)
+    local path = pl_path.join(root, 'config', name)
+
+    print('loading config for: ', environment, ' environment from ', path)
+
+    local config = loadfile(path, 't', {
+        print = print, inspect = require('inspect'),
+        pcall = pcall, require = require, assert = assert, error = error,
+    })
+
+    local default_config = {}
+
+    if not config then
+        return default_config, 'invalid config'
+    end
+
+    local table = config()
+
+    for k,v in pairs(self.default_config) do
+        if table[k] == nil then
+            table[k] = v
+        end
+    end
+
+    return table
+end
+
+return _M
diff --git a/apicast/src/apicast/template.lua b/apicast/src/apicast/template.lua
new file mode 100644
index 000000000..d43acf258
--- /dev/null
+++ b/apicast/src/apicast/template.lua
@@ -0,0 +1,99 @@
+local _M = {}
+
+local setmetatable = setmetatable
+local insert = table.insert
+local assert = assert
+local pairs = pairs
+local sub = string.sub
+local len = string.len
+local pack = table.pack
+local pl = { dir = require('pl.dir'), path = require('pl.path'), file = require('pl.file') }
+local Liquid = require 'liquid'
+local resty_env = require('resty.env')
+local Lexer = Liquid.Lexer
+local Parser = Liquid.Parser
+local Interpreter = Liquid.Interpreter
+local FilterSet = Liquid.FilterSet
+local InterpreterContext = Liquid.InterpreterContext
+local FileSystem = Liquid.FileSystem
+local ResourceLimit = Liquid.ResourceLimit
+
+local function noop(...) return ... end
+
+
+function _M:new(config, dir, strict)
+    local instance = setmetatable({}, { __index = self })
+    local env = {}
+
+    for name,value in pairs(resty_env.list()) do
+        insert(env, { name = name, value = value })
+    end
+
+    local context = setmetatable({
+        env = env,
+    }, { __index = config })
+
+    instance.root = pl.path.abspath(dir or pl.path.currentdir())
+    instance.context = InterpreterContext:new(context)
+    instance.strict = strict
+    instance.filesystem = FileSystem:new(function(path)
+        return instance:read(path)
+    end)
+
+    return instance
+end
+
+function _M:read(template_name)
+    local root = self.root
+    local check = self.strict and assert or noop
+
+    assert(template_name, 'missing template name')
+    return check(pl.file.read(pl.path.join(root, template_name)))
+end
+
+function _M:render(template_name)
+    local template = self:read(template_name)
+    return self:interpret(template)
+end
+
+local function starts_with(string, match)
+    return sub(string,1,len(match)) == match
+end
+
+function _M:interpret(str)
+    local lexer = Lexer:new(str)
+    local parser = Parser:new(lexer)
+    local interpreter = Interpreter:new(parser)
+    local context = self.context
+    local filesystem = self.filesystem
+    local filter_set = FilterSet:new()
+    local resource_limit = ResourceLimit:new(nil, 1000, nil)
+
+    filter_set:add_filter('filesystem', function(pattern)
+        local files = {}
+
+        for filename, dir in pl.dir.dirtree(self.root) do
+            local file = pl.path.relpath(filename, self.root)
+            if pl.dir.fnmatch(file, pattern) and not dir then
+                insert(files, file)
+            end
+        end
+
+        return files
+    end)
+
+    filter_set:add_filter('default', function(value, default)
+        return value or default
+    end)
+
+    filter_set:add_filter('starts_with', function(string, ...)
+        local matches = pack(...)
+        for i=1, matches.n do
+            if starts_with(string, matches[i]) then return true end
+        end
+    end)
+
+    return interpreter:interpret(context, filter_set, resource_limit, filesystem)
+end
+
+return _M
diff --git a/bin/cli b/bin/cli
new file mode 120000
index 000000000..a1aec1df0
--- /dev/null
+++ b/bin/cli
@@ -0,0 +1 @@
+../apicast/bin/cli
\ No newline at end of file