In part 1 we got our feets wet with some initial
simple Hello Cloud scripts, using F#. This was then expanded to do actual connectivity to AWS and list S3
buckets in an AWS account.
In this part, we will sidetrack a bit into the topic of developer workflow and then continue with more exploration of AWS services and get a bit more into F#, with scripts
to retrieve server information. This will be a step-but-step process, start simple and add features.
My description here aims at documenting a workflow where the script is developed, since I do believe that it is important to establish an (enjoyable) approach to develop code, in whatever language that may be. In this case, it is F#.
Developer workflow
IDE integration with F# Interactive REPL
In part 1 I mentioned only briefly about different IDEe (integrated development environments) with F# support
and my own preference with Jetbrains tools. I think a key element of a good developer workflow is a fast feedback
loop and ease of experimentation - a REPL-type environment like FSI is essential for this.
For me, I think the REPL-driven development process in Clojure has set kind of a gold standard for how things
should be in that area. Functional languages provide a good foundation for allowing such workflows, given
the focus on immutability and functional composition.
In both Jetbrains Rider, Visual Studio and Visual Studio Code (with the Ionide F# plugin) you can send expressions to
FSI through a simple keyboard shortcut or menu command and the expression will be evaluated in FSI and you can
also interact with the FSI REPL directly. This makes it quite easy to interact and experiment with code as you
write it and can get essentially immediate feedback.
It is a little bit different still from what I am used to with Clojure, but quite good and useful to fiddle around and test things.
Below is a very simple example with Hello Cloud of interaction with the REPL in Visual Studio Code, which I have switched to from Jetbrains Rider for the time being. For F# script development, I found the VS Code experience more enjoyable.
- I can select the hello function and then press Alt-Enter to send the selection to F# Interactive - which is started automatically if it is not already running. The selection is evaluated in the REPL.
- I have a few example calls at the bottom of the file, inside a comment block. If I press Alt-Enter without selecting anything, it will send the contents of the line to the REPL and it gets evaluated and I see the result immediately.
- This way there is a kind of scratch area for doing immediate feedback testing, which also is persisted
This is not a replacement for actual unit testing test suites, but a nice low overhead complement.
Using F# 5 features
In part 1 we took advantage of a new feature of F# 5, which allows us to specify dependencies to our scripts, which will be downloaded
by the .NET package manager (NuGet) automatically, if needed.
This is quite handy when developing standalone scripts for performing tasks towards our cloud provider
of choice, like our simple HelloS3.fsx
script in the previous blog post. While it worked fine to run
these scripts, we did not look at how this was supported in our developer workflow.
If you had worked with these scripts in some IDE with F# support, it may not have understood these new commands
for referencing dependencies. So in this case, make sure that the F# support in your editor/IDE of choice supports F# 5.
AWS credentials handling
The next part in enabling a workflow in the IDE with the F# Interactive REPL for communicating with a cloud provider is how to access the credentials set up. Previously we relied on being able to set environment variables to
be able to access the correct AWS profile.
We can do the same in the IDE. However, I did not find a way to configure which environment variables to set when
an F# Interactive REPL process is started and we just send the selected code to the REPL.
It is possible to set environment variables like AWS_PROFILE by setting the environment variable when we start the
IDE, e.g. when starting Visual Studio Code from the command line.
Processes started by Visual Studio Code will inherit the environment variables. So it can be started like this:
prompt> AWS_PROFILE=myprofile code
The path to Visual Studio Code needs to be added to the search path for the command line first also.
It is not ideal, but works.
It is also possible to add code in the scripts to set the necessary environment variable or fetch credentials
from an AWS profile explicitly
Adding a line
do Environment.SetEnvironmentVariable ("AWS_PROFILE", "myprofile")
before instantiating any client object would be one simple way to get basically the same effect - but probably not hardcode the value... It is a manageable problem anyway.
Note: The function Environment.SetEnvironmentVariable is something that is provided by .NET Core and is not something specific to F#. The way the function is called can give a hint there. It takes two parameters, but instead of being called as
Environment.SetEnvironmentVariable "AWS_PROFILE" "myprofile"
it is called as
Environment.SetEnvironmentVariable ("AWS_PROFILE", "myprofile")
It is in fact not two parameters that are sent to the function call, but instead a single parameter which is a tuple with two values.
Since the other official .NET languages do not support partial function application like F# do and have to provide all required
parameters in a call, I guess this is the approach taken in F# to interoperate with functions adapted for those other languages.
ShowServers.fsx
Our next task here is to show some information about virtual machines in AWS (a.k.a. EC2 instances). The first iteration should be simple, just show something about every EC2 instance in an AWS account in a specific region. We have the AWS SDK for .NET API docs and we can do a similar starting point that we did with HelloS3.fsx
in part 1.
#!/usr/bin/env dotnet fsi
// Get the AWS SDK packages needed
#r "nuget: AWSSDK.Core"
#r "nuget: AWSSDK.EC2"
open System.Environment
open Amazon.EC2
open Amazon.EC2.Model
let describeInstances (client: AmazonEC2Client) =
async {
let! response = client.DescribeInstancesAsync() |> Async.AwaitTask
return response
}
let showServers (args: string[]) =
if args.Length > 0 then
do SetEnvironmentVariable ("AWS_PROFILE", args.[0])
let client = new AmazonEC2Client()
let response = (describeInstances client) |> Async.RunSynchronously
response
showServers (fsi.CommandLineArgs |> Array.skip 1)
(*
showServers [| "erik" |]
GetEnvironmentVariable("AWS_PROFILE")
let response = showServers [| "erik" |]
*)
The structure is fairly similar to our previous S3-based example - create a client object for EC2 and call the function that
should return EC2 instance information. At this point, we also just return that result.
An addition is to take a string array as arguments, which we could pass to the script if called, and use the first
argument as the AWS profile name, if it is provided.
The workflow I use here is that the code here is sent from the editor to the F# Interactive REPL and try out various things
as the code is developed. The comment block at the end contains a few expressions that I add there while developing and testing
the code.
So we can use one expression with the REPL to call showServers and see if it works (see below).
We can use the autocompletion of the editor (VS Code) to see what fields we have in the response and check what is in there.
It seems from the first tests here that the call to AWS was successful and we have a field Reservations which is a list of
Reservation - and that list seems to be empty. Which in this case is correct, since the account and region I connected to
does not have any EC2 instances at all.
So time to create some...
EC2 instances created
I created two EC2 instances and now when calling the function and inspecting the result, this is the result:
There are two Reservation and each seems to contain a sequence of Instance, which should be the actual machines.
The rest of the information under Reservation we do not bother with for the time being. So we iterate over the list
of Reservation objects and in each of these, we get all the Instance objects. The result from that should be a single
list of Instance objects.
A new update of the code adds this type of functionality:
#!/usr/bin/env dotnet fsi
// Get the AWS SDK packages needed
#r "nuget: AWSSDK.Core"
#r "nuget: AWSSDK.EC2"
open System.Environment
open Amazon.EC2
open Amazon.EC2.Model
let getInstances (reservations: Reservation list) =
reservations |> List.collect (fun x -> List.ofSeq x.Instances)
let describeInstances (client: AmazonEC2Client) =
async {
let! response = client.DescribeInstancesAsync() |> Async.AwaitTask
return response
}
let showServers (args: string[]) =
if args.Length > 0 then
do SetEnvironmentVariable ("AWS_PROFILE", args.[0])
let client = new AmazonEC2Client()
let response = (describeInstances client) |> Async.RunSynchronously
if response.Reservations.Count = 0 then
List.empty<Instance>
else
(List.ofSeq response.Reservations) |> getInstances
showServers (fsi.CommandLineArgs |> Array.skip 1)
(*
showServers [| "erik" |]
GetEnvironmentVariable("AWS_PROFILE")
let mylist = List.ofSeq response.Reservations
let response = showServers [| "erik" |]
*)
The Reservations list is converted to a generic F# list and then provided as input to the new function getInstances.
This function calls the List.collect function, which iterates over a list and calls a function for each element
in the list. The result from that function call is expected to be a list itself. This list of lists is then flattened to a single list.
The function that is called is an anonymous function which we have defined using the fun keyword as
(fun x -> List.ofSeq x.Instances)
. The function takes a single argument, which in our case is a Reservation object.
From this object, we retrieve the list of instances from the Instances field and convert that to a generic F# list and
return that as the result of the function.
In our case we have also added a condition to only call getInstances if the reservations list is not empty, otherwise, we return an empty list of type Instance. With two instances we can see that we get some appropriate data back.
Extracting instance informastion
So now when we see that we can get Instance objects it is time to extract some information out of it. There is a lot of
information available for an instance, but not all of that may be useful at first glance. A couple of things comes to mind
that could be useful:
- The id of the instance
- The name of the instance, if that exists
- The type of instance (EC2 has type names that have specific properties such as CPU, RAM, etc)
- The private IP address and/or DNS name
- When it was launched
- The current state (running, stopped etc)
We can expand on that list later, but this looks like a reasonable starting point. So we should then have some kind of record in which we can store the information we want for each instance. A first stab at defining such a record structure could be:
type InstanceInfo =
{ InstanceId: string
Name: string
InstanceType: string
PrivateIpAddress: string
LaunchTime: DateTime
State: string }
Most of it is strings to start with right now. We could arguably refine this further and we will, but this is what we start with. For this then, we need a function that creates an InstanceInfo record from an Instance object. For all these fields
except the Name field, this is trivial. The Name field is the more tricky one since it is not mandatory and is
represented through a tag on the instance which is called "Name".
So first just skip the Name field issue and leave it empty and get an initial version. Notice here that the curly brackets
are used to group the fields of the InstanceInfo record instance we create - it is not any code brackets as in some other languages! Notice also that we have not specified anywhere that this is an InstanceInfo that is returned - F# figures that
out by itself given the shape of the record.
let getInstanceInfo (instance: Instance) =
{
InstanceId = instance.InstanceId
Name = ""
InstanceType = instance.InstanceType.Value
PrivateIpAddress = instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
and test that in the REPL:
so now deal with the issue with the Name field - if we keep the Name field as an empty string if the Name tag does not
exist, then we need to iterate through the list of tags and if there is a tag with its key-value "Name", then we take the
value of that tag and set that as the Name field.
Finding the Name tag
Let us break down what we want to do here into a few steps:
- We want to be able to iterate over all tags, so we want an iterable structure. We have a .NET:y list right now, but want to make it a more F#:y one. It could be handled already as in F# sequence, or we could have an F# list or an F# array.
- Once we have the iterable structure, we want to go through it and for each element check if it is the Name tag. If that is the case, we return the value of the tag. if we do not find the Name tag, we return an empty string.
- We use the result of the search above to initiate the Name field returned by getInstanceInfo.
Note here that we want to work with immutable data, so some constructs that may be more common in other object-oriented and imperative languages we will not use - even if F# technically allows us to do that. One example there is that we will not create
a default structure of InstanceInfo with an empty Name field and then after that populate it with a different value. We will
set the value to the correct one right from the start.
Among the EC2 instances I created, I happened to name one of them "Tiny Instance". We can create a list of Tag objects
and check the content in the REPL:
Cool, the first instance was that particular EC2 instance it seems! How do we then find the value of the Name tag?
It does not have to be at the first position in the list, even though it is so in this case.
The List module has a few functions that we could use to find it - one way would be to get the length and then
iterate over each element using an index to access each element. This is not optimal, even though this may be a way
to approach it in some other languages.
Another way though it breaks down the problem into primary cases:
- What do we do if get an empty list? We return an empty string.
- What do we do if the first element in the list is the Name tag? We return the value of that tag.
These two cases we can handle using what is called a match expression. Let us construct a function getTagValue
which handles those naive cases:
let getTagValue key (tags: Tag list) =
match tags with
| [] -> ""
| head :: tail -> if head.Key = key then head.Value else ""
The match expression is a quite powerful construct. Between match and with the expression that should be
matched is put. Then there is a list of pattern expressions to match that with, prefixed with | operator and after the
-> operator the result expression that is to be executed if there is a match is put.
So, in this case, we specify an empty list ( [] ) and if there is a match for that, we return an empty string.
The second case was if the first item in the list was the name tag. In the pattern expression, we specify the cons
operator ( :: ) with two names, one on each side - head and tail. The cons operator is a way to describe a
list as the first element and the rest of the list. In this has, head is the first element in the list and tail
is the rest of the list. F# will do this matching and assign the first element to the name head and the rest of the list
to the name tail.
So we can then check if the first element is the Name tag and if so, return the value. A Tag object has two fields, one named
Key and one named Value. So we simply check if the Key field is equal to the tag name we are looking for and then return
its value. Otherwise, we return the empty string.
This seems to work just fine:
So that is great and we have solved the problem for two easy cases. But what if the list of tags has multiple tags and
the Name tag is not in the first position?
Well, we can then just simply extend our solution slightly. In our pattern matching, we had the situation where the second pattern would give us the rest of the list. This means if we apply the same logic to the rest of the list, we will
either return an empty string if the rest of the list is empty or return the tag value if the Name tag is the first element
of the rest of the list. I.e. we get a recursive function, which can call itself.
There is only a tiny bit of modification to make this work:
let rec getTagValue key (tags: Tag list) =
match tags with
| [] -> ""
| head :: tail -> if head.Key = key then head.Value else getTagValue key tail
We add a rec keyword after the let keyword to indicate that the function will be recursive and then we simply
just call the function again in the else clause. Done! Now we can find the Name tag at any position in the list if it exists.
We then can update getInstanceInfo function to use this:
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateIpAddress = instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
and the complete script at this point is then:
#!/usr/bin/env dotnet fsi
// Get the AWS SDK packages needed
#r "nuget: AWSSDK.Core"
#r "nuget: AWSSDK.EC2"
open System
open System.Environment
open Amazon.EC2
open Amazon.EC2.Model
type InstanceInfo =
{ InstanceId: string
Name: string
InstanceType: string
PrivateIpAddress: string
LaunchTime: DateTime
State: string }
let rec getTagValue key (tags: Tag list) =
match tags with
| [] -> ""
| head :: tail -> if head.Key = key then head.Value else getTagValue key tail
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateIpAddress = instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
let getInstances (reservations: Reservation list) =
reservations |> List.collect (fun x -> List.ofSeq x.Instances)
let describeInstances (client: AmazonEC2Client) =
async {
let! response = client.DescribeInstancesAsync() |> Async.AwaitTask
return response
}
let showServers (args: string[]) =
if args.Length > 0 then
do SetEnvironmentVariable ("AWS_PROFILE", args.[0])
let client = new AmazonEC2Client()
let response = (describeInstances client) |> Async.RunSynchronously
if response.Reservations.Count = 0 then
List.empty<Instance>
else
(List.ofSeq response.Reservations) |> getInstances
showServers (fsi.CommandLineArgs |> Array.skip 1)
(*
showServers [| "erik" |]
GetEnvironmentVariable("AWS_PROFILE")
let mylist = List.ofSeq response.Reservations
let response = showServers [| "erik" |]
let iis = showServers [| "erik" |] |> List.map getInstanceInfo
getInstanceInfo response.[0]
let instance = response.[0]
let tags = List.ofSeq instance.Tags
getTagValue "Name" tags
*)
Show the result
Now we can get the result as a list of InstanceInfo objects. We can look at the result from that in the REPL, but would
perhaps be a bit nicer with some kind of print option. So we can make a function to print the contents of the
InstanceInfo list. A simple way to print stuff to the screen that we already have used is the printfn function,
so let us use that. We can print strings fine with printfn, but there is no built-in formatting code for DateTime
objects. So we can make a simple function to convert the DateTime to string, in a format we prefer.
I like the yyyy-mm-dd hh:mm:ss format, so the formatting function should do that:
let yyyymmddhhmmss (dt: DateTime) =
dt.ToString ("yyyy-MM-dd HH:mm:ss")
Next, let us make a function using printfn that prints some headers for the fields and then
the content of the InstanceInfo list, one item per row. The function is not expected to return anything,
we just want the side-effect of printing. Therefore to clarify this the return type of the function is
declared as unit. It is similar to void in some other languages, but there is an actual value for that type - not an absence of type as in some other languages.
let printInstanceInfo (instanceInfos: InstanceInfo list) : unit =
if instanceInfos.IsEmpty then
printfn "No instance info available!"
else
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
"InstanceId" "Name" "InstanceType" "Private IP" "LaunchTime" "State"
for ii in instanceInfos do
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
ii.InstanceId ii.Name ii.InstanceType ii.PrivateIpAddress (yyyymmddhhmmss ii.LaunchTime) ii.State
First handle the case if the list is empty, then take care of non-empty lists. There we print a heading first
and then iterate over the list, printing each item. The heading is created in the same way as the items, using
the same approach to make it easier to manage to format. This works well:
Note though that the format string is actually not a regular string type, so it cannot be replaced with a simple
named value string.
We tweak the main function showServers a bit to use this function also and then we get the current result of the script
as this:
#!/usr/bin/env dotnet fsi
// Get the AWS SDK packages needed
#r "nuget: AWSSDK.Core"
#r "nuget: AWSSDK.EC2"
open System
open System.Environment
open Amazon.EC2
open Amazon.EC2.Model
type InstanceInfo =
{ InstanceId: string
Name: string
InstanceType: string
PrivateIpAddress: string
LaunchTime: DateTime
State: string }
let rec getTagValue key (tags: Tag list) =
match tags with
| [] -> ""
| head :: tail -> if head.Key = key then head.Value else getTagValue key tail
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateIpAddress = instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
let yyyymmddhhmmss (dt: DateTime) =
dt.ToString ("yyyy-MM-dd HH:mm:ss")
let printInstanceInfo (instanceInfos: InstanceInfo list) : unit =
if instanceInfos.IsEmpty then
printfn "No instance info available!"
else
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
"InstanceId" "Name" "InstanceType" "Private IP" "LaunchTime" "State"
for ii in instanceInfos do
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
ii.InstanceId ii.Name ii.InstanceType ii.PrivateIpAddress (yyyymmddhhmmss ii.LaunchTime) ii.State
let getInstances (reservations: Reservation list) =
reservations |> List.collect (fun x -> List.ofSeq x.Instances)
let describeInstances (client: AmazonEC2Client) =
async {
let! response = client.DescribeInstancesAsync() |> Async.AwaitTask
return response
}
let showServers (args: string[]) =
if args.Length > 0 then
do SetEnvironmentVariable ("AWS_PROFILE", args.[0])
let client = new AmazonEC2Client()
let response = (describeInstances client) |> Async.RunSynchronously
let infolist =
if response.Reservations.Count = 0 then
List.empty<Instance>
else
(List.ofSeq response.Reservations) |> getInstances
infolist |> List.map getInstanceInfo |> printInstanceInfo
showServers (fsi.CommandLineArgs |> Array.skip 1)
(*
showServers [| "erik" |]
GetEnvironmentVariable("AWS_PROFILE")
let mylist = List.ofSeq response.Reservations
let response = showServers [| "erik" |]
let iis = showServers [| "erik" |] |> List.map getInstanceInfo
showServers [| "erik" |] |> List.map getInstanceInfo |> printInstanceInfo
getInstanceInfo response.[0]
let instance = response.[0]
let tags = List.ofSeq instance.Tags
getTagValue "Name" tags
*)
We can call the new showServers from the REPL:
or we can run the script from the command line:
The trouble with types
The type we have used in our script to represent instance information (InstanceInfo) is for the
most part using just plain strings to represent different values. With the very limited scope, our
script has now, that might not be a big issue. But if we go much beyond what we have now, then this
may potentially become a problem once the codebase grows larger.
We have a few different fields in InstanceInfo that are not just plain strings:
- The InstanceId is an identifier with a specific format of the string, e.g. "my dog is cute" would not be a valid value here.
- InstanceType is actually a string representation of multiple entities, each with a limited range of values
- PrivateIpAddress will only be something with a valid IP address representation. e.g. "123.234.234.123".
- State will also have a limited set of values it can have, e.g. "running", "stopped", "terminated" etc.
The good thing is that the type system in F# can help us catch issues that might happen if we would
just use primitive type values like strings for everything. It is beyond the scope of this post
to build out full-blown types with validation of formats and all possible cases for all the fields
we included, but I want to include just a very simple addition that at least makes it clear in
code what a string should represent if we use it.
Let us take the PrivateIpAddress field. This is an IP address, so let's make an IPAddress type and use that instead in our InstanceInfo record:
type IpAddress = IpAddress of string
type InstanceInfo =
{ InstanceId: string
Name: string
InstanceType: string
PrivateIpAddress: IpAddress
LaunchTime: DateTime
State: string }
The type declaration for IpAddress may look a bit strange - does it somehow refer to itself?
The IpAddress to the left of the equal sign is the type name. The IpAddress on the right side of the equal sign is part of the type value. It essentially says that a value of the type IpAddress must consist of an identifier named IpAddress and then a value of type string.
We then change the type of the PrivateIpAddress field to use this. If you try to run the script
now, it will fail and you will have multiple errors. The first one is where we assign the value
to this field in the function. Instead of:
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateIpAddress = instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
we will not be able to assign the string from instance.PrivateIpAddress
directly, we need to
tell it that the string is to be used for the IpAddress type. So we need to add the identifier that is part of the type value:
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateIpAddress = IpAddress instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
This is then good for the assignment of the value. But there will still be errors elsewhere, the
call to print the values will fail, because now the value to print is not a string type, but an IpAddress type!
One way we can address this (there are multiple ways) is to define a conversion function from
our IpAddress type to a string and then use that function when we are going to print the value.
This is a simple one-liner, which we put next to our type definition so we have them grouped together:
type IpAddress = IpAddress of string
let toString (IpAddress s) = s
The toString function takes one parameter that consists of two parts, the identifier IpAddress and s. Then the function will simply return s. Given that our type IpAddress is defined
with an identifier IpAddress and then a string value, the compiler will know that s will be of
type string in this case.
Then we need to change our printfn call to use this conversion function:
for ii in instanceInfos do
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
ii.InstanceId ii.Name ii.InstanceType (toString ii.PrivateIpAddress) (yyyymmddhhmmss ii.LaunchTime) ii.State
This is a small change to make it a bit more explicit in the code that it is an IP address we are
dealing with. So now our complete script looks like this:
#!/usr/bin/env dotnet fsi
// Get the AWS SDK packages needed
#r "nuget: AWSSDK.Core"
#r "nuget: AWSSDK.EC2"
open System
open System.Environment
open Amazon.EC2
open Amazon.EC2.Model
type IpAddress = IpAddress of string
let toString (IpAddress a) = a
type InstanceInfo =
{ InstanceId: string
Name: string
InstanceType: string
PrivateIpAddress: IpAddress
LaunchTime: DateTime
State: string }
let rec getTagValue key (tags: Tag list) =
match tags with
| [] -> ""
| head :: tail -> if head.Key = key then head.Value else getTagValue key tail
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateIpAddress = IpAddress instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
let yyyymmddhhmmss (dt: DateTime) =
dt.ToString ("yyyy-MM-dd HH:mm:ss")
let printInstanceInfo (instanceInfos: InstanceInfo list) : unit =
if instanceInfos.IsEmpty then
printfn "No instance info available!"
else
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
"InstanceId" "Name" "InstanceType" "Private IP" "LaunchTime" "State"
for ii in instanceInfos do
printfn "%-20s %-20s %-16s %-16s %-20s %-10s"
ii.InstanceId ii.Name ii.InstanceType (toString ii.PrivateIpAddress) (yyyymmddhhmmss ii.LaunchTime) ii.State
let getInstances (reservations: Reservation list) =
reservations |> List.collect (fun x -> List.ofSeq x.Instances)
let describeInstances (client: AmazonEC2Client) =
async {
let! response = client.DescribeInstancesAsync() |> Async.AwaitTask
return response
}
let showServers (args: string[]) =
if args.Length > 0 then
do SetEnvironmentVariable ("AWS_PROFILE", args.[0])
let client = new AmazonEC2Client()
let response = (describeInstances client) |> Async.RunSynchronously
let infolist =
if response.Reservations.Count = 0 then
List.empty<Instance>
else
(List.ofSeq response.Reservations) |> getInstances
infolist |> List.map getInstanceInfo |> printInstanceInfo
showServers (fsi.CommandLineArgs |> Array.skip 1)
(*
showServers [| "erik" |]
GetEnvironmentVariable("AWS_PROFILE")
let mylist = List.ofSeq response.Reservations
let response = showServers [| "erik" |]
let iis = showServers [| "erik" |] |> List.map getInstanceInfo
showServers [| "erik" |] |> List.map getInstanceInfo |> printInstanceInfo
getInstanceInfo response.[0]
let instance = response.[0]
let tags = List.ofSeq instance.Tags
getTagValue "Name" tags
*)
Is that worth the effort? Let us say that we want to expand our code to include either a private
IP address or a DNS name, but not both. Can we do that in a way that let us be clear what is there
and capture potential issues?
Let us change our type IpAddress to be a type that can represent either an IP address or a DNS name. We can do that easily:
type IpAddressOrDns =
| IpAddress of string
| DnsName of string
Now we have type IpAddressOrDns that have two possible type values; either an IP address or a DNS name. Each of these cases is represented by an identifier (IpAddress or DnsName) and then a value of string type.
Once we create this change you will see that F# will report an error on the toString function we defined, which likely will look like the one in the picture below:
The F# compiler knows that this pattern is from our IpAddressOrDns type and it also knows
that you have not handled all cases of what that type can be, what case you have not taken care of here and tells you what it is.
So we need to update toString to handle both cases:
let toString v =
match v with
| IpAddress s -> s
| DnsName s -> s
We do not specify the cases we have in the parameter declaration, we just set a parameter name.
Instead, we rely on pattern matching in the function itself.
We do not need to specify any type information here explicitly, since the compiler knows what
type v is and what type s is since it knows how the type IpAddressOrDns is defined. The code compiles now since we have covered all possible cases for the IpAddressOrDns type.
We need to change the type of the field in InstanceInfo from IpAddress to IpAddressOfDns and then the script will work again. But we may also want to rename the field PrivateIpAddress to perhaps PrivateHostAddress, if that is a field that actually should be able to contain either
an IP address or a DNS name.
With this update the script code looks like this:
#!/usr/bin/env dotnet fsi
// Get the AWS SDK packages needed
#r "nuget: AWSSDK.Core"
#r "nuget: AWSSDK.EC2"
open System
open System.Environment
open Amazon.EC2
open Amazon.EC2.Model
type IpAddressOrDns =
| IpAddress of string
| DnsName of string
let toString v =
match v with
| IpAddress s -> s
| DnsName s -> s
type InstanceInfo =
{ InstanceId: string
Name: string
InstanceType: string
PrivateHostAddress: IpAddressOrDns
LaunchTime: DateTime
State: string }
let rec getTagValue key (tags: Tag list) =
match tags with
| [] -> ""
| head :: tail -> if head.Key = key then head.Value else getTagValue key tail
let getInstanceInfo (instance: Instance) =
let tags = List.ofSeq instance.Tags
{
InstanceId = instance.InstanceId
Name = getTagValue "Name" tags
InstanceType = instance.InstanceType.Value
PrivateHostAddress = IpAddress instance.PrivateIpAddress
LaunchTime = instance.LaunchTime
State = instance.State.Name.Value
}
let yyyymmddhhmmss (dt: DateTime) =
dt.ToString ("yyyy-MM-dd HH:mm:ss")
let printInstanceInfo (instanceInfos: InstanceInfo list) : unit =
if instanceInfos.IsEmpty then
printfn "No instance info available!"
else
printfn "%-20s %-20s %-16s %-21s %-20s %-10s"
"InstanceId" "Name" "InstanceType" "Private Host Address" "LaunchTime" "State"
for ii in instanceInfos do
printfn "%-20s %-20s %-16s %-21s %-20s %-10s"
ii.InstanceId ii.Name ii.InstanceType (toString ii.PrivateHostAddress) (yyyymmddhhmmss ii.LaunchTime) ii.State
let getInstances (reservations: Reservation list) =
reservations |> List.collect (fun x -> List.ofSeq x.Instances)
let describeInstances (client: AmazonEC2Client) =
async {
let! response = client.DescribeInstancesAsync() |> Async.AwaitTask
return response
}
let showServers (args: string[]) =
if args.Length > 0 then
do SetEnvironmentVariable ("AWS_PROFILE", args.[0])
let client = new AmazonEC2Client()
let response = (describeInstances client) |> Async.RunSynchronously
let infolist =
if response.Reservations.Count = 0 then
List.empty<Instance>
else
(List.ofSeq response.Reservations) |> getInstances
infolist |> List.map getInstanceInfo |> printInstanceInfo
showServers (fsi.CommandLineArgs |> Array.skip 1)
(*
showServers [| "erik" |]
GetEnvironmentVariable("AWS_PROFILE")
let mylist = List.ofSeq response.Reservations
let response = showServers [| "erik" |]
let iis = showServers [| "erik" |] |> List.map getInstanceInfo
showServers [| "erik" |] |> List.map getInstanceInfo |> printInstanceInfo
getInstanceInfo response.[0]
let instance = response.[0]
let tags = List.ofSeq instance.Tags
getTagValue "Name" tags
*)
That is it for now!
Side note: If you get an error like this below, it means that whatever function you are calling has been compiled against
a different version of the record than you are passing to it when you are interacting with the REPL. This would not be an issue in Clojure, but a statically typed language this may happen in the REPL interaction.
In that case, you need to make sure you re-evaluate all the code pieces that have been affected by your type change!
Final remarks
After struggling a bit with getting an enjoyable experience in developing F# scripts, I decided to focus
a bit more on workflow aspects in this post, before digging a bit deeper into cloud use cases.
I believe that a good REPL and good use of a REPL is a key factor to enjoyable development workflow.
Clojure is really great in that area and F# grows on me as well here.
I think both of these languages do a good job here and they are both perhaps more focused on an editor<->REPL integration workflow, more than extended interactive sessions in the REPL itself.
Other languages which I think have pretty nice REPL experience include Julia and R. If there are further improvements to the REPL experience for F# I think both of these may serve well as inspiration. Julia was probably inspired by R in what to include in the REPL, is my guess.
I hope this was at least somewhat useful for some of you!
In part 3 we are going to start with some F# code using AWS Lambda.
Source code
The source code in the blog posts in this series is posted to this Github repository:
Top comments (0)