Pulumi Automation API - Static Websites as a RESTful API
Lets get a little bit meta on this example. I am going to show you how to use a SvelteKit application with the Pulumi Automation API to create other static websites!
When to Use Pulumi Automation API
The Pulumi Automation API is a programmatic interface for running Pulumi programs without the Pulumi CLI. It allows you to embed Pulumi within your application code, making it easy to create custom experiences on top of Pulumi that are tailored to your use-case, domain, and team.
The Automation API is available in the Node, Python, Go, .NET, and Java SDKs. It provides a number of features that make it a powerful tool for building custom infrastructure automation solutions, including:
- Strongly typed: The Automation API is strongly typed, which means that you can be confident that your code will work as expected. This is in contrast to the Pulumi CLI, which is dynamically typed.
- Safe: The Automation API is safe, which means that it is difficult to make mistakes that could lead to infrastructure failures. This is because the Automation API uses a number of safeguards, such as type checking and validation, to prevent errors.
- Extensible: The Automation API is extensible, which means that you can customize it to meet your specific needs. This can be done by adding new commands, plugins, and other features.
The Automation API can be used for a variety of purposes, including:
- CI/CD integration: The Automation API can be used to integrate Pulumi with your CI/CD pipeline. This allows you to automate the deployment of your infrastructure to production.
- Integration testing: The Automation API can be used to write integration tests for your Pulumi programs. This helps to ensure that your infrastructure is working as expected.
- Multi-stage deployments: The Automation API can be used to support multi-stage deployments, such as blue-green deployments. This allows you to deploy new versions of your infrastructure without disrupting existing users.
- Deployments involving application code: The Automation API can be used to deploy infrastructure that is involved in the deployment of your application code. This can include things like database migrations and load balancers.
- Building higher level tools: The Automation API can be used to build higher level tools, such as custom CLIs over Pulumi. This can make it easier for developers to use Pulumi to manage their infrastructure.
If you are looking for a powerful tool for building custom infrastructure automation solutions, the Pulumi Automation API is a great option. It is available in a variety of languages and provides a number of features that make it a safe and extensible solution.
Pulumi Over Http
This example is meant to show how you can use Pulumi's Automation API to create static websites hosted on AWS. The example utilizes SvelteKit API's so that you can expose infrastructure as RESTful resources. All the infrastructure is defined in inline programs that are constructed and altered on the fly based on input parsed from user-specified POST bodies. Be sure to read through the handlers to see how Automation API detect structured error cases such as update conflicts (409), and missing stacks (404).
You will find all of the code needed inside of src/routes/api/stacks
and src/routes/api/stacks/[id]
.
SvelteKit Example Steps
- First You enter content that is needed for the website you are creating, then click Create Site
- A Pulumi stack is created with a Pulumi program that adds an
index.html
to a new bucket. - The stack has an output for
websiteUrl
that will show the new website.
Pre-Requirements needed to run the example
- A Pulumi CLI installation (v3.0.0 or later)
- The AWS CLI, with appropriate credentials. If you are on a Mac I would highly recommend using Brew's AWS CLI if this is a first time setup. The below setup is how you can quickly set your aws access (although this is not best practice).
aws configure
AWS Access Key ID [*************xxxx]: <Your AWS Access Key ID>
AWS Secret Access Key [**************xxxx]: <Your AWS Secret Access Key>
Default region name: [us-east-2]: us-east-2
Default output format [None]: json
Run example localhost
Clone example
git clone https://github.com/codercatdev/pulumi-over-http.git
Install necessary packages
pnpm install
Run Dev Environment
pnpm dev
List stacks
If you have used this example in the past you will see a listing page appear, if not you will see a message "No Sites Found".
In the src/routes/+page.ts
we are using a load function to get our stacks from an API that we created.
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
const stacksRes = await fetch('/api/stacks');
const stacks = await stacksRes.json();
return {
stacks
};
}) satisfies PageLoad;
This is a GET
http call that is calling our API found within src/routes/api/stacks/+server.ts
.
import { listHandler } from './pulumi';
/** @type {import('./$types').RequestHandler} */
export const GET = async () => {
return listHandler();
};
This GET method calls our listHandler
function within src/routes/api/stacks/pulumi.ts
, which lists stacks within a project called pulumi_over_http
.
const projectName = 'pulumi_over_http';
// lists all sites
export const listHandler = (async () => {
try {
// set up a workspace with only enough information for the list stack operations
const ws = await LocalWorkspace.create({
projectSettings: { name: projectName, runtime: 'nodejs' }
});
const stacks = await ws.listStacks();
return new Response(JSON.stringify(stacks));
} catch (e) {
return new Response(JSON.stringify(e), { status: 500 });
}
}) satisfies RequestHandler;
Create Site
On the homepage add the below code to the input box replacing "Hello World!" and click the "Create Site" button.
<h1>Xena</h1>
<p>A domesticated, black crazy cat.</p>
<img
src="https://media.codingcat.dev/image/upload/q_auto,f_auto,w_800/main-codingcatdev-photo/xena-blackcatui.jpg"
alt="black cat sleeping"
/>
This form can be found in src/lib/CreateSite.svelte
and includes the onCreate
function to call our API.
<script lang="ts">
let creating = false;
let content = 'Hello World!';
const onCreate = async () => {
creating = true;
const resp = await fetch('/api/stacks', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: Date.now(), content })
});
const respJson = await resp.json();
if (resp.status > 200) {
alert('Error occured, see console.');
console.error(content);
creating = false;
} else {
window.location.href = `/${respJson.id}`;
}
};
</script>
<form class="flex w-full gap-2">
<textarea class="flex-1 text-black" bind:value={content} />
<button class="btn variant-filled-primary" on:click={() => onCreate()} disabled={creating}>
{#if creating}
Creating...
{:else}
Create Site
{/if}
</button>
</form>
We have a new method inside of src/routes/api/stacks/+server.ts
that handles this POST
call and calls the createHandler
function.
import { createHandler, listHandler } from './pulumi';
/** @type {import('./$types').RequestHandler} */
export const GET = async () => {
return listHandler();
};
export const POST = async (requestEvent) => {
return createHandler(requestEvent);
};
The createHandler
function within src/routes/api/stacks/pulumi.ts
, creates the new site within AWS.
// creates new sites
export const createHandler = (async ({ request }) => {
const { id, content } = await request.json();
const stackName = id;
try {
// create a new stack
const stack = await LocalWorkspace.createStack({
stackName,
projectName,
// generate our pulumi program on the fly from the POST body
program: createPulumiProgram(content)
});
await stack.setConfig('aws:region', { value: 'us-west-2' });
// deploy the stack, tailing the logs to console
const upRes = await stack.up({ onOutput: console.info });
return new Response(JSON.stringify({ id: stackName, url: upRes.outputs.websiteUrl.value }));
} catch (e) {
if (e instanceof StackAlreadyExistsError) {
return new Response(`stack "${stackName}" already exists`, { status: 409 });
} else {
return new Response(JSON.stringify(e), { status: 500 });
}
}
}) satisfies RequestHandler;
A crucial part of this call is LocalWorkspace.createStack
in which we pass createPulumiProgram
. This program is what takes our input HTML, creates an S3 bucket, sets the correct permissions on the bucket, and creates the index.html file inside of that bucket, which returns the new websiteEndpoint.
// this function defines our pulumi S3 static website in terms of the content that the caller passes in.
// this allows us to dynamically deploy websites based on user defined values from the POST body.
const createPulumiProgram = (content: string) => async () => {
// Create a bucket and expose a website index document
const siteBucket = new s3.Bucket('s3-website-bucket', {
website: {
indexDocument: 'index.html'
}
});
// Allow for policy update
new s3.BucketPublicAccessBlock('bucketPublicAccessBlock', {
bucket: siteBucket.bucket,
blockPublicPolicy: false
});
// here our HTML is defined based on what the caller curries in.
const indexContent = content;
// write our index.html into the site bucket
new s3.BucketObject('index', {
bucket: siteBucket,
content: indexContent,
contentType: 'text/html; charset=utf-8',
key: 'index.html'
});
// Create an S3 Bucket Policy to allow public read of all objects in bucket
const publicReadPolicyForBucket = (bucketName: string): PolicyDocument => {
const policy: PolicyDocument = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: [
`arn:aws:s3:::${bucketName}/*` // policy refers to bucket name explicitly
]
}
]
};
console.log('POLICY: ', JSON.stringify(policy));
return policy;
};
// Set the access policy for the bucket so all objects are readable
new s3.BucketPolicy('bucketPolicy', {
bucket: siteBucket.bucket, // refer to the bucket created earlier
policy: siteBucket.bucket.apply(publicReadPolicyForBucket) // use output property `siteBucket.bucket`
});
return {
websiteUrl: siteBucket.websiteEndpoint
};
};
When all of this finishes happening you will be forwarded to the stack page for your site.
View Stack
In the stack page view for your new site you will see the site url which will open the full website, along with an iframe view of that site which you just created.
In order to get the data for this stack, first capture the id in our Svelte load function at src/routes/[id]/+page.ts
. You can do this by using the params object that is passed to the load.
import type { PageLoad } from './$types';
export const load = (async ({ fetch, params }) => {
const stacksRes = await fetch(`/api/stacks/${params.id}`);
if (stacksRes.status > 200) {
console.error(stacksRes.status, stacksRes.statusText);
}
const stacks = await stacksRes.json();
return {
id: params.id,
url: stacks?.url
};
}) satisfies PageLoad;
We get the stacks information again from our API, in a new location src/routes/api/stacks/[id]/+server.ts
that requires our id to be used in the typing for the params.
import { getHandler } from './pulumi';
export const GET = async (requestEvent) => {
return getHandler(requestEvent);
};
The GET
function calls the getHandler
within src/routes/api/stacks/[id]/pulumi.ts
and as you can see below we get the stack id from params.id
and assign it to stackName
.
// gets info about a specific site
export const getHandler = (async ({ params }) => {
const stackName = params?.id;
try {
// select the existing stack
const stack = await LocalWorkspace.selectStack({
stackName,
projectName,
// don't need a program just to get outputs
// eslint-disable-next-line @typescript-eslint/no-empty-function
program: async () => {}
});
const outs = await stack.outputs();
return new Response(JSON.stringify({ id: stackName, url: outs.websiteUrl.value }));
} catch (e) {
if (e instanceof StackNotFoundError) {
return new Response(`stack "${stackName}" does not exist`, { status: 404 });
} else {
return new Response(JSON.stringify(e), { status: 500 });
}
}
}) satisfies RequestHandler;
Delete Stack
Now that the stack is created, it can be deleted by clicking the button "Delete Stack". This will remove all of the associated resources within AWS and remove your website.
The Svelte component can be found at src/lib/DeleteStack.svelte
and it contains the function onDelete
.
<script lang="ts">
export let id = '';
let deleting = false;
const onDelete = async () => {
deleting = true;
const resp = await fetch(`/api/stacks/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
if (resp.status > 200) {
console.error(resp.statusText);
} else {
window.location.href = `/`;
}
};
</script>
<form>
<button
class="btn btn-sm variant-filled-secondary"
on:click={() => onDelete()}
disabled={deleting}
>
{#if deleting}
Deleting...
{:else}
Delete Stack: {id}
{/if}
</button>
</form>
Within the onDelete
function you can see the fetch call to /api/stacks/${id}
with the method DELETE
. This API can be found at src/routes/api/stacks/[id]/+server.ts
.
import { deleteHandler, getHandler } from './pulumi';
export const GET = async (requestEvent) => {
return getHandler(requestEvent);
};
export const DELETE = async (requestEvent) => {
return deleteHandler(requestEvent);
};
As you can see the DELETE
function calls deleteHandler
within src/routes/api/stacks/[id]/pulumi.ts
. Once again using the Pulumi Automation API you can use the LocalWorkspace class to select the correct stack and then call the destroy and remove methods.
export const deleteHandler = (async ({ params }) => {
const stackName = params.id;
try {
// select the existing stack
const stack = await LocalWorkspace.selectStack({
stackName,
projectName,
// don't need a program for destroy
// eslint-disable-next-line @typescript-eslint/no-empty-function
program: async () => {}
});
// deploy the stack, tailing the logs to console
await stack.destroy({ onOutput: console.info });
await stack.workspace.removeStack(stackName);
return new Response(undefined, { status: 200 });
} catch (e) {
if (e instanceof StackNotFoundError) {
return new Response(`stack "${stackName}" does not exist`, { status: 404 });
} else if (e instanceof ConcurrentUpdateError) {
return new Response(`stack "${stackName}" already has update in progress`, { status: 409 });
} else {
return new Response(JSON.stringify(e), { status: 500 });
}
}
}) satisfies RequestHandler;
Stack Update
For bonus points I have left a PUT
method in src/routes/api/stacks/[id]/+server.ts
which calls theupdateHandler
function in src/routes/api/stacks/[id]/pulumi.ts
.
Try using the below code to wire up an update to the current stack for changing the website.
// updates the content for an existing site
export const updateHandler = (async ({ request, params }) => {
const stackName = params?.id;
const { content } = await request.json();
try {
// select the existing stack
const stack = await LocalWorkspace.selectStack({
stackName,
projectName,
// generate our pulumi program on the fly from the POST body
program: createPulumiProgram(content)
});
await stack.setConfig('aws:region', { value: 'us-west-2' });
// deploy the stack, tailing the logs to console
const upRes = await stack.up({ onOutput: console.info });
return new Response(JSON.stringify({ id: stackName, url: upRes.outputs.websiteUrl.value }));
} catch (e) {
if (e instanceof StackNotFoundError) {
return new Response(`stack "${stackName}" does not exist`, { status: 404 });
} else if (e instanceof ConcurrentUpdateError) {
return new Response(`stack "${stackName}" already has update in progress`, { status: 409 });
} else {
return new Response(JSON.stringify(e), { status: 500 });
}
}
}) satisfies RequestHandler;