Home

Awesome

Introduction

TesTcl is a Tcl library for unit testing iRules which are used when configuring F5 BIG-IP devices.

News

Getting started

If you're familiar with unit testing and mocking in particular, using TesTcl should't be to hard. Check out the examples below:

Simple example

Let's say you want to test the following simple iRule found in simple_irule.tcl:

rule simple {

  when HTTP_REQUEST {
    if { [HTTP::uri] starts_with "/foo" } {
      pool foo
    } else {
      pool bar
    }
  }

  when HTTP_RESPONSE {
    HTTP::header remove "Vary"
    HTTP::header insert Vary "Accept-Encoding"
  }

}

Now, create a file called test_simple_irule.tcl containing the following lines:

package require -exact testcl 1.0.14
namespace import ::testcl::*

# Comment in to enable logging
#log::lvSuppressLE info 0

it "should handle request using pool bar" {
  event HTTP_REQUEST
  on HTTP::uri return "/bar"
  endstate pool bar
  run simple_irule.tcl simple
}

it "should handle request using pool foo" {
  event HTTP_REQUEST
  on HTTP::uri return "/foo/admin"
  endstate pool foo
  run simple_irule.tcl simple
}

it "should replace existing Vary http response headers with Accept-Encoding value" {
  event HTTP_RESPONSE
  verify "there should be only one Vary header" 1 == {HTTP::header count vary}
  verify "there should be Accept-Encoding value in Vary header" "Accept-Encoding" eq {HTTP::header Vary}
  HTTP::header insert Vary "dummy value"
  HTTP::header insert Vary "another dummy value"
  run simple_irule.tcl simple
}

Installing JTcl including jtcl-irule extensions

Install JTcl

Download JTcl, unzip it and add it to your path.

Add jtcl-irule to your JTcl installation

Add the jtcl-irule extension to JTcl. If you don't have the time to build it yourself, you can download the jar artifact from the release v 0.9 page or you can use the direct link. Next, copy the jar file into the directory where you installed JTcl. Add jtcl-irule to the classpath in jtcl or jtcl.bat. IMPORTANT! Make sure you place the jtcl-irule-0.9.jar on the classpath before the standard jtcl-<version>.jar

MacOS X and Linux

On MacOs X and Linux, this can be achieved by putting the following line just above the last line in the jtcl shell script

export CLASSPATH=$dir/jtcl-irule-0.9.jar:$CLASSPATH
Windows

On Windows, modify the following line in jtcl.bat from

set cp="%dir%\jtcl-%jtclver%.jar;%CLASSPATH%"

to

set cp="%dir%\jtcl-irule-0.9.jar;%dir%\jtcl-%jtclver%.jar;%CLASSPATH%"
Verify installation

Create a script file named test_jtcl_irule.tcl containing the following lines

if {"aa" starts_with "a"} {
  puts "The jtcl-irule extension has successfully been installed"
}

and execute it using

jtcl test_jtcl_irule.tcl

You should get a success message. If you get a message saying syntax error in expression ""aa" starts_with "a"": variable references require preceding $, jtcl-irule is not on the classpath before the standard jtcl-<version>.jar. Please review instructions above.

Add the testcl library to your library path

Download latest TesTcl distribution from github containing all the files (including examples) found in the project. Unzip, and add unzipped directory to the TCLLIBPATH environment variable:

On MacOS X and Linux:

export TCLLIBPATH=whereever/TesTcl-1.0.14

On Windows, create a System Variable named TCLLIBPATH and make sure that the path uses forward slashes '/'

In order to run this example, type in the following at the command-line:

>jtcl test_simple_irule.tcl

This should give you the following output:

**************************************************************************
* it should handle request using pool bar
**************************************************************************
-> Test ok

**************************************************************************
* it should handle request using pool foo
**************************************************************************
-> Test ok

**************************************************************************
* it should replace existing Vary http response headers with Accept-Encoding value
**************************************************************************
verification of 'there should be only one Vary header' done.
verification of 'there should be Accept-Encoding value in Vary header' done.
-> Test ok

Explanations

A word on the TesTcl commands
A word on stubs or mockups (you choose what to call 'em)#####
HTTP namespace

Most of the other commands in the HTTP namespace have been implemented. We've done our best, but might have missed some details. Look at the sourcecode if you wonder what is going on in the mocks. In particular, the HTTP::header mockup implementation should work as expected. However insert_modssl_fields subcommand is not supported in current version.

URI namespace

Everything should be supported, with the exception of:

which is only partially supported.

GLOBAL namespace

Support for

Avoiding code duplication using the before command

In order to avoid code duplication, one can use the before command. The argument passed to the before command will be executed before the following it specifications.

NB! Be carefull with using on commands in before. If there will be another definition of the same expectation in it statement, only first one will be in use (this one set in before).

Using the before command, test_simple_irule.tcl can be rewritten as:

package require -exact testcl 1.0.14
namespace import ::testcl::*

# Comment in to enable logging
#log::lvSuppressLE info 0

before {
  event HTTP_REQUEST
}

it "should handle request using pool bar" {
  on HTTP::uri return "/bar"
  endstate pool bar
  run simple_irule.tcl simple
}

it "should handle request using pool foo" {
  on HTTP::uri return "/foo/admin"
  endstate pool foo
  run simple_irule.tcl simple
}

it "should replace existing Vary http response headers with Accept-Encoding value" {
  # NB! override event type set in before
  event HTTP_RESPONSE

  verify "there should be only one Vary header" 1 == {HTTP::header count vary}
  verify "there should be Accept-Encoding value in Vary header" "Accept-Encoding" eq {HTTP::header Vary}
  HTTP::header insert Vary "dummy value"
  HTTP::header insert Vary "another dummy value"
  run irules/simple_irule.tcl simple
}

On a side note, it's worth mentioning that there is no after command, since we're always dealing with mocks.

Advanced example

Let's have a look at a more advanced iRule (advanced_irule.tcl):

rule advanced {

  when HTTP_REQUEST {

    HTTP::header insert X-Forwarded-SSL true

    if { [HTTP::uri] eq "/admin" } {
      if { ([HTTP::username] eq "admin") && ([HTTP::password] eq "password") } {
        set newuri [string map {/admin/ /} [HTTP::uri]]
        HTTP::uri $newuri
        pool pool_admin_application
      } else {
        HTTP::respond 401 WWW-Authenticate "Basic realm=\"Restricted Area\""
      }
    } elseif { [HTTP::uri] eq "/blocked" } {
      HTTP::respond 403
    } elseif { [HTTP::uri] starts_with "/app"} {
      if { [active_members pool_application] == 0 } {
        if { [HTTP::header User-Agent] eq "Apache HTTP Client" } {
          HTTP::respond 503
        } else {
          HTTP::redirect "http://fallback.com"
        }
      } else {
        set newuri [string map {/app/ /} [HTTP::uri]]
        HTTP::uri $newuri
        pool pool_application
      }
    } else {
      HTTP::respond 404
    }

  }

}

The specs for this iRule would look like this:

package require -exact testcl 1.0.14
namespace import ::testcl::*

# Comment out to suppress logging
#log::lvSuppressLE info 0

before {
  event HTTP_REQUEST
}

it "should handle admin request using pool admin when credentials are valid" {
  HTTP::uri "/admin"
  on HTTP::username return "admin"
  on HTTP::password return "password"
  endstate pool pool_admin_application
  run irules/advanced_irule.tcl advanced
}

it "should ask for credentials when admin request with incorrect credentials" {
  HTTP::uri "/admin"
  HTTP::header insert Authorization "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
  verify "user Aladdin" "Aladdin" eq {HTTP::username}
  verify "password 'open sesame'" "open sesame" eq {HTTP::password}
  verify "WWW-Authenticate header is 'Basic realm=\"Restricted Area\"'" "Basic realm=\"Restricted Area\"" eq {HTTP::header "WWW-Authenticate"}
  verify "response status code is 401" 401 eq {HTTP::status}
  run irules/advanced_irule.tcl advanced
}

it "should ask for credentials when admin request without credentials" {
  HTTP::uri "/admin"
  verify "WWW-Authenticate header is 'Basic realm=\"Restricted Area\"'" "Basic realm=\"Restricted Area\"" eq {HTTP::header "WWW-Authenticate"}
  verify "response status code is 401" 401 eq {HTTP::status}
  run irules/advanced_irule.tcl advanced
}

it "should block access to uri /blocked" {
  HTTP::uri "/blocked"
  endstate HTTP::respond 403
  run irules/advanced_irule.tcl advanced
}

it "should give apache http client a correct error code when app pool is down" {
  HTTP::uri "/app"
  on active_members pool_application return 0
  HTTP::header insert User-Agent "Apache HTTP Client"
  endstate HTTP::respond 503
  run irules/advanced_irule.tcl advanced
}

it "should give other clients then apache http client redirect to fallback when app pool is down" {
  HTTP::uri "/app"
  on active_members pool_application return 0
  HTTP::header insert User-Agent "Firefox 13.0.1"
  verify "response status code is 302" 302 eq {HTTP::status}
  verify "Location header is 'http://fallback.com'" "http://fallback.com" eq {HTTP::header Location}
  run irules/advanced_irule.tcl advanced
}

it "should give handle app request using app pool when app pool is up" {
  HTTP::uri "/app/form?test=query"
  on active_members pool_application return 2
  endstate pool pool_application
  verify "result uri is /form?test=query" "/form?test=query" eq {HTTP::uri}
  verify "result path is /form" "/form" eq {HTTP::path}
  verify "result query is test=query" "test=query" eq {HTTP::query}
  run irules/advanced_irule.tcl advanced
}

it "should give 404 when request cannot be handled" {
  HTTP::uri "/cannot_be_handled"
  endstate HTTP::respond 404
  run irules/advanced_irule.tcl advanced
}

stats

Modification of HTTP headers example

Let's have a look at another iRule (headers_irule.tcl):

rule headers {

  #notify backend about SSL using X-Forwarded-SSL http header
  #if there is client certificate put common name into X-Common-Name-SSL http header
  #if not make sure X-Common-Name-SSL header is not set
  when HTTP_REQUEST {
    HTTP::header insert X-Forwarded-SSL true
    HTTP::header remove X-Common-Name-SSL
    
    if { [SSL::cert count] > 0 } {
      set ssl_cert [SSL::cert 0]
      set subject [X509::subject $ssl_cert]
      set cn ""
      foreach { label value } [split $subject ",="] {
        set label [string toupper [string trim $label]]
        set value [string trim $value]
        
        if { $label == "CN" } {
          set cn "$value"
          break
        }
      }
    
      HTTP::header insert X-Common-Name-SSL "$cn"
    }
  }

}

The example specs for this iRule would look like this:

package require -exact testcl 1.0.14
namespace import ::testcl::*

# Comment out to suppress logging
#log::lvSuppressLE info 0

before {
  event HTTP_REQUEST
  verify "There should be always set HTTP header X-Forwarded-SSL to true" true eq {HTTP::header X-Forwarded-SSL}
}

it "should remove X-Common-Name-SSL header from request if there was no client SSL certificate" {
  HTTP::header insert X-Common-Name-SSL "testCommonName"
  on SSL::cert count return 0
  verify "There should be no X-Common-Name-SSL" 0 == {HTTP::header exists X-Common-Name-SSL}
  run irules/headers_irule.tcl headers
}

it "should add X-Common-Name-SSL with Common Name from client SSL certificate if it was available" {
  on SSL::cert count return 1
  on SSL::cert 0 return {}
  on X509::subject [SSL::cert 0] return "CN=testCommonName,DN=abc.de.fg"
  verify "X-Common-Name-SSL HTTP header value is the same as CN" "testCommonName" eq {HTTP::header X-Common-Name-SSL}
  run irules/headers_irule.tcl headers
}

Classes Example

TesTcl has partial support for the class command. For example, we could test the following rule:

rule classes {
  when HTTP_REQUEST {
    if { [class match [IP::remote_addr] eq blacklist] } {
      drop
    } else {
      pool main-pool
    }
  }
}

with code that looks like this

package require -exact testcl 1.0.14
namespace import testcl::*

before {
  event HTTP_REQUEST
  class configure blacklist {
    "192.168.6.66" "blacklisted"
  }
}

it "should drop blacklisted addresses" {
  on IP::remote_addr return "192.168.6.66"
  endstate drop
  run irules/classes.tcl classes
}

it "should not drop addresses that are not blacklisted" {
  on IP::remote_addr return "192.168.0.1"
  endstate pool main-pool
  run irules/classes.tcl classes
}

How stable is this code?

This work is quite stable, but you can expect minor breaking changes.

Why I created this project

Configuring BIG-IP devices is no trivial task, and typically falls in under a DevOps kind of role. In order to make your system perform the best it can, you need:

Most shops test iRules manually, the procedure typically being a variation of the following:

There are lots of issues with this manual approach:

Clearly, manual testing is not the way forward!

Test matrix and compatibility

Mac Os XWindowsCygwin
JTcl 2.4.0yesyesyes
JTcl 2.5.0yesyesyes
JTcl 2.6.0yesyesyes
JTcl 2.7.0yesyesyes
JTcl 2.8.0yesyesyes
Tclsh 8.6yes*yes*?

The * indicates support only for standard Tcl commands

If you use TesTcl on a different platform, please let us know

Getting help

Post questions to the group at TesTcl user group
File bugs over at github

Contributing code

See CONTRIBUTING.md

Who uses it?

Well, I can't really tell you, but according to Google Analytics, this site gets around 10 hits per day.

License

Just like JTcl, TesTcl is licensed under a BSD-style license.

Please please please

Drop me a line if you use this library and find it useful: stefan.landro you know what gmail.com

You can also check out my LinkedIn profile or my Google+ profile, or even my twitter account - follow it for TesTcl releases