Resource hooks
Posted on
Pulumi programs are declarative, allowing you to specify the desired state of your infrastructure while Pulumi figures out the rest. But what about the times where you want to be more involved in what Pulumi is doing? Resource hooks are one of our most requested features, and from Pulumi 3.182.0 we’re excited to announce that you’ll be able to use them to run arbitrary code at any point in Pulumi’s resource lifecycle!
When you run pulumi up
, Pulumi runs your program and works out the set of create, update and delete operations required to realise the state it describes. Hooks allow you to attach custom callbacks to these operations, enabling you to execute custom behaviour before or after they occur. You might want to set up an SSH tunnel to a bastion host before Pulumi attempts to create or update a virtual machine or database; or you may wish to send metrics to your data warehouse whenever Pulumi updates certain resources or properties. Hooks allow you to do all this and more – as with everything else in Pulumi, you are free to make use of the full power of your favourite programming language.
Hooks in action
Let’s check out the use case of opening a tunnel to a remote host before running a command. For this we’ll use a contrived example where we use the socat
command to open a local TCP port that forwards to a remote host. Here we’ll open localhost:1234
and forward it to example.com:80
. We’ll then use Pulumi’s command
provider to invoke curl
to make a request to that local port, which will be forwarded to the remote host. To start with, let’s write the program without hooks:
import * as command from "@pulumi/command"
export = async () => {
const curl = "curl -H'Host: example.com' localhost:1234"
const cmd = new command.local.Command("curl", {
create: curl,
update: curl,
delete: curl,
triggers: [new Date().toString()],
})
return {
stdout: cmd.stdout,
}
}
import * as command from "@pulumi/command"
export = async () => {
const curl = "curl -H'Host: example.com' localhost:1234"
const cmd = new command.local.Command("curl", {
create: curl,
update: curl,
delete: curl,
triggers: [new Date().toString()],
})
return {
stdout: cmd.stdout,
}
}
import datetime
import pulumi
import pulumi_command
curl = "curl -H'Host: example.com' localhost:1234"
cmd = pulumi_command.local.Command("curl",
create=curl,
update=curl,
delete=curl,
triggers=[str(datetime.datetime.now())],
)
pulumi.export("stdout", cmd.stdout)
package main
import (
"time"
"github.com/pulumi/pulumi-command/sdk/go/command/local"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
curl := "curl -H'Host: example.com' localhost:1234"
cmd, err := local.NewCommand(ctx, "curl", &local.CommandArgs{
Create: pulumi.String(curl),
Update: pulumi.String(curl),
Delete: pulumi.String(curl),
Triggers: pulumi.StringArray{pulumi.String(time.Now().String())},
})
if err != nil {
return err
}
ctx.Export("stdout", cmd.Stdout)
return nil
})
}
using System.Threading.Tasks;
using Pulumi;
using Pulumi.Command.Local;
class Program
{
static Task<int> Main() => Deployment.RunAsync<MyStack>();
}
class MyStack : Stack
{
public MyStack()
{
var curl = "curl -H'Host: example.com' localhost:1234";
var cmd = new Command("curl", new CommandArgs
{
Create = curl,
Update = curl,
Delete = curl,
Triggers = { System.DateTime.Now.ToString() },
});
return new Dictionary<string, object?>
{
["stdout"] = cmd.Stdout,
};
}
}
We set up a local Command
that will run the same curl
command whether it is being created, updated, or deleted. For the purposes of illustration, we also set the triggers
property to the current date and time, so that the command will always be run when we run pulumi up
. Let’s now do just that:
pulumi up
Assuming we have nothing running on port 1234
, we’ll get an error such as the following:
Updating (...)
View in Browser (Ctrl+O): https://app.pulumi.com/...
Type Name Status Info
pulumi:pulumi:Stack resource-hooks **failed** 1 error; 9 messages
++ └─ command:local:Command curl **creating failed** [diff: ]; 1 error
Diagnostics:
command:local:Command (curl):
error: exit status 7: running "curl -H'Host: example.com' localhost:1234":
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
curl: (7) Failed to connect to localhost port 1234 after 0 ms: Could not connect to server
As expected, nothing is listening on port 1234
, so curl
fails with an error. Previously, we might have fixed this by using a wrapped around our Pulumi program, such as the Automation API. With resource hooks, however, the solution is at our fingertips! We’ll hook into the Command
resource’s lifecycle to open a tunnel before any operation is run, and to close it afterwards:
import * as child_process from "child_process"
import * as command from "@pulumi/command"
import * as pulumi from "@pulumi/pulumi"
export = async () => {
let tunnel
const beforeHook = new pulumi.ResourceHook("before", async args => {
console.log("Opening tunnel")
tunnel = child_process.spawn(
"socat",
[
"TCP-LISTEN:1234,fork",
"TCP:example.com:80",
],
{ detached: true }
)
console.log(`Tunnel opened: ${tunnel?.pid}`)
})
const afterHook = new pulumi.ResourceHook("after", async args => {
console.log(`Closing tunnel: ${tunnel?.pid}`)
tunnel?.kill("SIGKILL")
})
const curl = "curl -H'Host: example.com' localhost:1234"
const cmd = new command.local.Command("curl", {
create: curl,
update: curl,
delete: curl,
triggers: [new Date().toString()],
}, {
hooks: {
beforeCreate: [beforeHook],
afterCreate: [afterHook],
beforeUpdate: [beforeHook],
afterUpdate: [afterHook],
beforeDelete: [beforeHook],
afterDelete: [afterHook],
},
})
return {
stdout: cmd.stdout,
}
}
import * as child_process from "child_process"
import * as command from "@pulumi/command"
import * as pulumi from "@pulumi/pulumi"
export = async () => {
let tunnel: child_process.ChildProcessWithoutNullStreams | undefined
const beforeHook = new pulumi.ResourceHook("before", async args => {
console.log("Opening tunnel")
tunnel = child_process.spawn(
"socat",
[
"TCP-LISTEN:1234,fork",
"TCP:example.com:80",
],
{ detached: true }
)
console.log(`Tunnel opened: ${tunnel?.pid}`)
})
const afterHook = new pulumi.ResourceHook("after", async args => {
console.log(`Closing tunnel: ${tunnel?.pid}`)
tunnel?.kill("SIGKILL")
})
const curl = "curl -H'Host: example.com' localhost:1234"
const cmd = new command.local.Command("curl", {
create: curl,
update: curl,
delete: curl,
triggers: [new Date().toString()],
}, {
hooks: {
beforeCreate: [beforeHook],
afterCreate: [afterHook],
beforeUpdate: [beforeHook],
afterUpdate: [afterHook],
beforeDelete: [beforeHook],
afterDelete: [afterHook],
},
})
return {
stdout: cmd.stdout,
}
}
import datetime
import subprocess
import pulumi
import pulumi_command
tunnel = None
def before(args: pulumi.ResourceHookArgs):
global tunnel
pulumi.log.info("Opening tunnel")
tunnel = subprocess.Popen(
["socat", "TCP-LISTEN:1234,fork", "TCP:example.com:80"],
)
pulumi.log.info(f"Tunnel opened: {tunnel.pid}")
before_hook = pulumi.ResourceHook("before", before)
def after(args: pulumi.ResourceHookArgs):
global tunnel
if tunnel:
pulumi.log.info(f"Closing tunnel: {tunnel.pid}")
tunnel.terminate()
tunnel.wait()
tunnel = None
after_hook = pulumi.ResourceHook("after", after)
curl = "curl -H'Host: example.com' localhost:1234"
cmd = pulumi_command.local.Command(
"curl",
create=curl,
update=curl,
delete=curl,
triggers=[str(datetime.datetime.now())],
opts=pulumi.ResourceOptions(
hooks=pulumi.ResourceHookBinding(
before_create=[before_hook],
after_create=[after_hook],
before_update=[before_hook],
after_update=[after_hook],
before_delete=[before_hook],
after_delete=[after_hook],
)
),
)
pulumi.export("stdout", cmd.stdout)
package main
import (
"fmt"
"os/exec"
"time"
"github.com/pulumi/pulumi-command/sdk/go/command/local"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
var tunnel *exec.Cmd
func before(args *pulumi.ResourceHookArgs) error {
fmt.Println("Opening tunnel")
tunnel = exec.Command("socat", "TCP-LISTEN:1234,fork", "TCP:example.com:80")
err := tunnel.Start()
if err != nil {
return fmt.Errorf("failed to start tunnel: %w", err)
}
fmt.Printf("Tunnel opened: %d\n", tunnel.Process.Pid)
return nil
}
func after(args *pulumi.ResourceHookArgs) error {
if tunnel != nil && tunnel.Process != nil {
fmt.Printf("Closing tunnel: %d", tunnel.Process.Pid)
if err := tunnel.Process.Kill(); err != nil {
return err
}
tunnel.Wait()
tunnel = nil
}
return nil
}
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
beforeHook, err := ctx.RegisterResourceHook("before", before, nil)
if err != nil {
return err
}
afterHook, err := ctx.RegisterResourceHook("after", after, nil)
if err != nil {
return err
}
curlCmd := "curl -H'Host: example.com' localhost:1234"
cmd, err := local.NewCommand(ctx, "curl", &local.CommandArgs{
Create: pulumi.String(curlCmd),
Update: pulumi.String(curlCmd),
Delete: pulumi.String(curlCmd),
Triggers: pulumi.Array{pulumi.String(fmt.Sprintf("%d", time.Now().Unix()))},
}, pulumi.ResourceHooks(&pulumi.ResourceHookBinding{
BeforeCreate: []*pulumi.ResourceHook{beforeHook},
AfterCreate: []*pulumi.ResourceHook{afterHook},
BeforeUpdate: []*pulumi.ResourceHook{beforeHook},
AfterUpdate: []*pulumi.ResourceHook{afterHook},
BeforeDelete: []*pulumi.ResourceHook{beforeHook},
AfterDelete: []*pulumi.ResourceHook{afterHook},
}),
)
if err != nil {
return err
}
ctx.Export("stdout", cmd.Stdout)
return nil
})
}
using System.Diagnostics;
using System.Threading.Tasks;
using Pulumi;
using Pulumi.Command.Local;
class Program
{
static Task<int> Main() => Deployment.RunAsync<MyStack>();
}
class MyStack : Stack
{
public MyStack()
{
Process? tunnel = null;
var beforeHook = new ResourceHook("before", async (args, cancellationToken) =>
{
System.Console.WriteLine("Opening tunnel");
tunnel = Process.Start(new ProcessStartInfo
{
FileName = "socat",
Arguments = "TCP-LISTEN:1234,fork TCP:example.com:80",
UseShellExecute = false,
CreateNoWindow = true,
});
System.Console.WriteLine($"Tunnel opened: {tunnel?.Id}");
});
var afterHook = new ResourceHook("after", async (args, cancellationToken) =>
{
System.Console.WriteLine($"Closing tunnel: {tunnel?.Id}");
tunnel?.Kill();
});
var curl = "curl -H'Host: example.com' localhost:1234";
var cmd = new Command("curl", new CommandArgs
{
Create = curl,
Update = curl,
Delete = curl,
Triggers = { System.DateTime.Now.ToString() },
}, new CustomResourceOptions
{
Hooks =
{
BeforeCreate = { beforeHook },
AfterCreate = { afterHook },
BeforeUpdate = { beforeHook },
AfterUpdate = { afterHook },
BeforeDelete = { beforeHook },
AfterDelete = { afterHook },
}
});
return new Dictionary<string, object?>
{
["stdout"] = cmd.Stdout,
};
}
}
When we run pulumi up
now, the hooks will be invoked before and after the relevant operation. Since our previous operation failed, we should expect to see a hooked create on our first run:
pulumi up
Updating (...)
View in Browser (Ctrl+O): https://app.pulumi.com/...
Type Name Status Info
+ pulumi:pulumi:Stack resource-hooks created (3s) 3 messages
+ └─ command:local:Command curl created (0.66s)
Diagnostics:
pulumi:pulumi:Stack (resource-hooks):
Opening tunnel
Tunnel opened: 2131167
Closing tunnel: 2131167
Outputs:
stdout: "<!doctype html>\n<html>\n<head>\n <title>Example Domain</title>\n\n <meta charset=\"utf-8\" />\n <meta http-equiv=\"Content-type\" content=\"text/html; charset=utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <style type=\"text/css\">\n body {\n background-color: #f0f0f2;\n margin: 0;\n padding: 0;\n font-family: -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", \"Open Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n \n }\n div {\n width: 600px;\n margin: 5em auto;\n padding: 2em;\n background-color: #fdfdff;\n border-radius: 0.5em;\n box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);\n }\n a:link, a:visited {\n color: #38488f;\n text-decoration: none;\n }\n @media (max-width: 700px) {\n div {\n margin: 0 auto;\n width: auto;\n }\n }\n </style> \n</head>\n\n<body>\n<div>\n <h1>Example Domain</h1>\n <p>This domain is for use in illustrative examples in documents. You may use this\n domain in literature without prior coordination or asking for permission.</p>\n <p><a href=\"https://www.iana.org/domains/example\">More information...</a></p>\n</div>\n</body>\n</html>"
Resources:
+ 2 created
Duration: 5s
Success! The tunnel was opened before our create operation and our stack output contains the HTML of the page at example.com
.
Another example: application health checks
Let’s tackle a more realistic scenario where hooks can be useful. We’ll take the how-to guide for creating an EC2 web server and add resource hooks to health check the web server before allowing Pulumi to mark the deployment as complete. In this way, we know that when Pulumi is finished, the application is up and ready to go!
import * as aws from "@pulumi/aws"
import * as pulumi from "@pulumi/pulumi"
export = async () => {
// Define the instance's user data script to set up a simple HTTP server that
// serves some static content. We'll try to fetch the health.json in a
// lifecycle hook later on to check whether or not the server is up and running.
const userData = `#!/bin/bash
echo "Hello, World!" > index.html
echo '{"ok": true}' > health.json
nohup python -m SimpleHTTPServer 80 &`
// Define a resource hook that will run after create and update and not return
// until the health check passes.
const afterHook = new pulumi.ResourceHook("after", async args => {
// Since this is an after hook, we'll have access to the new outputs of the
// resource.
const outputs = args.newOutputs
// Attempt to fetch health.json from the instance's public endpoint, backing
// off linearly if it is not yet available.
const maxRetries = 30
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(`http://${outputs.publicDns}/health.json`)
if (response.ok) {
const data = await response.json()
console.log(`Health check passed: ${JSON.stringify(data)}`)
return
}
} catch (error) {
console.log(`Health check attempt ${i + 1} failed`)
}
await new Promise(resolve => setTimeout(resolve, (i + 1) * 1000))
}
})
// Set up the resources needed to run the web server, as outlined in the
// how-to guide linked above.
const instanceType = "t2.micro"
const ami = aws.ec2.getAmiOutput({
filters: [{
name: "name",
values: ["amzn2-ami-hvm-*"],
}],
// Amazon owns this AMI so we'll use their owner ID.
owners: ["137112412989"],
mostRecent: true,
})
const group = new aws.ec2.SecurityGroup("webserver-secgrp", {
ingress: [
{ protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] },
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
],
})
const server = new aws.ec2.Instance("webserver-www", {
instanceType,
vpcSecurityGroupIds: [group.id],
ami: ami.id,
userData,
}, {
hooks: {
afterCreate: [afterHook],
afterUpdate: [afterHook],
},
})
return {
publicDns: server.publicDns,
publicIp: server.publicIp,
}
}
import * as aws from "@pulumi/aws"
import * as pulumi from "@pulumi/pulumi"
export = async () => {
// Define the instance's user data script to set up a simple HTTP server that
// serves some static content. We'll try to fetch the health.json in a
// lifecycle hook later on to check whether or not the server is up and running.
const userData = `#!/bin/bash
echo "Hello, World!" > index.html
echo '{"ok": true}' > health.json
nohup python -m SimpleHTTPServer 80 &`
// Define a resource hook that will run after create and update and not return
// until the health check passes.
const afterHook = new pulumi.ResourceHook("after", async args => {
// Since this is an after hook, we'll have access to the new outputs of the
// resource.
const outputs = args.newOutputs as aws.ec2.InstanceState
// Attempt to fetch health.json from the instance's public endpoint, backing
// off linearly if it is not yet available.
const maxRetries = 30
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(`http://${outputs.publicDns}/health.json`)
if (response.ok) {
const data = await response.json()
console.log(`Health check passed: ${JSON.stringify(data)}`)
return
}
} catch (error) {
console.log(`Health check attempt ${i + 1} failed`)
}
await new Promise(resolve => setTimeout(resolve, (i + 1) * 1000))
}
})
// Set up the resources needed to run the web server, as outlined in the
// how-to guide linked above.
const instanceType = "t2.micro"
const ami = aws.ec2.getAmiOutput({
filters: [{
name: "name",
values: ["amzn2-ami-hvm-*"],
}],
// Amazon owns this AMI so we'll use their owner ID.
owners: ["137112412989"],
mostRecent: true,
})
const group = new aws.ec2.SecurityGroup("webserver-secgrp", {
ingress: [
{ protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] },
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
],
})
const server = new aws.ec2.Instance("webserver-www", {
instanceType,
vpcSecurityGroupIds: [group.id],
ami: ami.id,
userData,
}, {
hooks: {
afterCreate: [afterHook],
afterUpdate: [afterHook],
},
})
return {
publicDns: server.publicDns,
publicIp: server.publicIp,
}
}
import json
import time
import pulumi
import requests
from pulumi_aws import ec2
# Define the instance's user data script to set up a simple HTTP server that
# serves some static content. We'll try to fetch the health.json in a
# lifecycle hook later on to check whether or not the server is up and running.
user_data = """#!/bin/bash
echo "Hello, World!" > index.html
echo '{"ok": true}' > health.json
nohup python -m SimpleHTTPServer 80 &"""
# Define a resource hook that will run after create and update and not return
# until the health check passes.
def health_check(args: pulumi.ResourceHookArgs):
# Since this is an after hook, we'll have access to the new outputs of the
# resource.
outputs = args.new_outputs
# Attempt to fetch health.json from the instance's public endpoint, backing
# off linearly if it is not yet available.
max_retries = 30
for i in range(max_retries):
try:
response = requests.get(
f"http://{outputs['publicDns']}/health.json", timeout=10
)
if response.status_code == 200:
data = response.json()
print(f"Health check passed: {json.dumps(data)}")
return
except Exception as error:
print(f"Health check attempt {i + 1} failed: {error}")
# Linear backoff - wait (i + 1) seconds before next attempt
time.sleep(i + 1)
instance_type = "t2.micro"
ami = ec2.get_ami_output(
filters=[
{
"name": "name",
"values": ["amzn2-ami-hvm-*"],
}
],
# Amazon owns this AMI so we'll use their owner ID.
owners=["137112412989"],
most_recent=True,
)
group = ec2.SecurityGroup(
"webserver-secgrp",
ingress=[
{
"protocol": "tcp",
"from_port": 22,
"to_port": 22,
"cidr_blocks": ["0.0.0.0/0"],
},
{
"protocol": "tcp",
"from_port": 80,
"to_port": 80,
"cidr_blocks": ["0.0.0.0/0"],
},
],
)
server = ec2.Instance(
"webserver-www",
instance_type=instance_type,
vpc_security_group_ids=[group.id],
ami=ami.id,
user_data=user_data,
opts=pulumi.ResourceOptions(
hooks=pulumi.ResourceHookBinding(
after_create=[health_check],
after_update=[health_check],
),
),
)
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// Define a resource hook that will run after create and update and not return
// until the health check passes.
func healthCheck(args *pulumi.ResourceHookArgs) error {
// Since this is an after hook, we'll have access to the new outputs of the
// resource.
publicDns := args.NewOutputs["publicDns"].StringValue()
// Attempt to fetch health.json from the instance's public endpoint, backing
// off linearly if it is not yet available.
maxRetries := 30
for i := 0; i < maxRetries; i++ {
url := fmt.Sprintf("http://%s/health.json", publicDns)
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(url)
if err == nil && resp.StatusCode == 200 {
var data map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&data); err == nil {
resp.Body.Close()
dataJSON, _ := json.Marshal(data)
fmt.Printf("Health check passed: %s\n", string(dataJSON))
return nil
}
resp.Body.Close()
}
if resp != nil {
resp.Body.Close()
}
fmt.Printf("Health check attempt %d failed: %v\n", i+1, err)
// Linear backoff - wait (i + 1) seconds before next attempt
time.Sleep(time.Duration(i+1) * time.Second)
}
return fmt.Errorf("health check failed after %d attempts", maxRetries)
}
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
// Define the instance's user data script to set up a simple HTTP server
// that serves some static content. We'll try to fetch the health.json
// in a lifecycle hook later on to check whether or not the server is up
// and running.
userData := `#!/bin/bash
echo "Hello, World!" > index.html
echo '{"ok": true}' > health.json
nohup python -m SimpleHTTPServer 80 &`
instanceType := "t2.micro"
ami, err := ec2.LookupAmi(ctx, &ec2.LookupAmiArgs{
Filters: []ec2.GetAmiFilter{
{
Name: "name",
Values: []string{"amzn2-ami-hvm-*"},
},
},
// Amazon owns this AMI so we'll use their owner ID.
Owners: []string{"137112412989"},
MostRecent: pulumi.BoolRef(true),
})
if err != nil {
return err
}
group, err := ec2.NewSecurityGroup(ctx, "webserver-secgrp", &ec2.SecurityGroupArgs{
Ingress: ec2.SecurityGroupIngressArray{
&ec2.SecurityGroupIngressArgs{
Protocol: pulumi.String("tcp"),
FromPort: pulumi.Int(22),
ToPort: pulumi.Int(22),
CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
},
&ec2.SecurityGroupIngressArgs{
Protocol: pulumi.String("tcp"),
FromPort: pulumi.Int(80),
ToPort: pulumi.Int(80),
CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
},
},
})
if err != nil {
return err
}
hook, err := ctx.RegisterResourceHook("health-check", healthCheck, nil)
if err != nil {
return err
}
server, err := ec2.NewInstance(ctx, "webserver-www", &ec2.InstanceArgs{
InstanceType: pulumi.String(instanceType),
VpcSecurityGroupIds: pulumi.StringArray{group.ID()},
Ami: pulumi.String(ami.Id),
UserData: pulumi.String(userData),
}, pulumi.ResourceHooks(&pulumi.ResourceHookBinding{
AfterCreate: []*pulumi.ResourceHook{hook},
AfterUpdate: []*pulumi.ResourceHook{hook},
}))
if err != nil {
return err
}
ctx.Export("publicDns", server.PublicDns)
ctx.Export("publicIp", server.PublicIp)
return nil
})
}
using System;
using System.Threading.Tasks;
using Pulumi;
using Pulumi.Aws.Ec2;
using Pulumi.Aws.Ec2.Inputs;
using Pulumi.Command.Local;
using System.Diagnostics;
using System.Collections.Generic;
class Program
{
static Task<int> Main() => Deployment.RunAsync<MyStack>();
}
class MyStack : Stack
{
public MyStack()
{
// Define the instance's user data script to set up a simple HTTP server that
// serves some static content. We'll try to fetch the health.json in a
// lifecycle hook later on to check whether or not the server is up and running.
var userData = @"#!/bin/bash
echo ""Hello, World!"" > index.html
echo '{""ok"": true}' > health.json
nohup python -m SimpleHTTPServer 80 &";
// Define a resource hook that will run after create and update and not return
// until the health check passes.
var afterHook = new ResourceHook("after", async (args, cancellationToken) =>
{
// Since this is an after hook, we'll have access to the new outputs of the
// resource.
var outputs = args.NewOutputs
// Attempt to fetch health.json from the instance's public endpoint, backing
// off linearly if it is not yet available.
const int maxRetries = 30;
for (var i = 0; i < maxRetries; i++)
{
try
{
using var client = new HttpClient();
var response = await client.GetAsync($"http://{outputs?["publicDns"]}/health.json");
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Health check passed: {data}");
return;
}
}
catch (Exception)
{
Console.WriteLine($"Health check attempt {i + 1} failed");
}
await Task.Delay((i + 1) * 1000);
}
});
// Set up the resources needed to run the web server, as outlined in the
// how-to guide linked above.
var instanceType = "t2.micro";
var ami = GetAmi.Invoke(new GetAmiArgs
{
Filters =
{
new GetAmiFilterArgs
{
Name = "name",
Values = { "amzn2-ami-hvm-*" },
},
},
Owners = { "137112412989" }, // Amazon owns this AMI so we'll use their owner ID.
MostRecent = true,
});
var group = new SecurityGroup("webserver-secgrp", new SecurityGroupArgs
{
Ingress =
{
new SecurityGroupIngressArgs
{
Protocol = "tcp",
FromPort = 22,
ToPort = 22,
CidrBlocks = { "0.0.0.0/0" },
},
new SecurityGroupIngressArgs
{
Protocol = "tcp",
FromPort = 80,
ToPort = 80,
CidrBlocks = { "0.0.0.0/0" },
},
},
});
var server = new Instance("webserver-www", new InstanceArgs
{
InstanceType = instanceType,
VpcSecurityGroupIds = { group.Id },
Ami = ami.Apply(a => a.Id),
UserData = userData,
}, new CustomResourceOptions
{
Hooks =
{
AfterCreate = { afterHook },
AfterUpdate = { afterHook },
}
});
// Export the public DNS and IP of the server.
this.PublicDns = server.PublicDns;
this.PublicIp = server.PublicIp;
}
[Output]
public Output<string> PublicDns { get; private set; }
[Output]
public Output<string> PublicIp { get; private set; }
}
Thanks to our hooks, pulumi up
will now wait until the web server is up and running before marking the deployment as complete. We can see this in action thanks to our logging output:
pulumi up
Updating (...)
View in Browser (Ctrl+O): https://app.pulumi.com/...
Type Name Status Info
+ pulumi:pulumi:Stack resource-hooks created (79s) 6 messages
+ ├─ aws:ec2:SecurityGroup webserver-secgrp created (3s)
+ └─ aws:ec2:Instance webserver-www created (67s)
Diagnostics:
pulumi:pulumi:Stack (resource-hooks):
Health check attempt 1 failed
Health check attempt 2 failed
Health check attempt 3 failed
Health check attempt 4 failed
Health check attempt 5 failed
Health check passed: {"ok":true}
Outputs:
publicHostName: "ec2-XXX-YYY-ZZZ-WWW.us-west-2.compute.amazonaws.com"
publicIp : "XXX.YYY.ZZZ.WWW"
Resources:
+ 3 created
Duration: 1m21s
And indeed, a command such as curl http://ec2-XXX-YYY-ZZZ-WWW.us-west-2.compute.amazonaws.com/health.json
returns {"ok":true}
immediately, without us having to wait for the web server to start up after the pulumi up
command completes!
Where can we go from here?
Resource hooks are a powerful new feature that allow you to run custom code at any point in the resource lifecycle. This opens up a wide range of possibilities, such as:
- Custom application health checks after creating or updating resources
- Collecting logs or metrics when your Pulumi resources change
- Running custom scripts such as database migrations as part of your Pulumi workflow.
refresh
and destroy
operations, this means you must use the --run-program
flag for hooks to work. Read the full documentation on resource hooks to learn more.Share any issues with your experience with us on GitHub, X, or our Community Slack.