Skip to content

Commit

Permalink
DS-917: Deploy webhook (#351)
Browse files Browse the repository at this point in the history
* DS-917: Deploy webhook with delegation.
  • Loading branch information
sherakama authored Oct 9, 2024
1 parent ab1e18e commit 0776970
Show file tree
Hide file tree
Showing 5 changed files with 377 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ You can find Architectural Decision Records and more documentation in the [docs]
Environment variable set up and installation
---

For more information on what the environment variables are and do see: [Environment Vars](./docs/environment-variables.md)

_Development_

1. Create a new `.env` file by cloning the `example.env` file provided:
Expand Down
83 changes: 83 additions & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ENVIRONMENT VARIABLES

Environment variables are contextually used in Netlify. See [this documentation](https://www.npmjs.com/package/netlify-plugin-contextual-env) for information about using the prefix naming convention. You can use this strategy in this project.

For example:

Default value: `MY_TOKEN`
Production value: `PRODUCTION_MY_TOKEN`
Preview value: `DEPLOY_PREVIEW_MY_TOKEN`
Dev branch value: `DEV_MY_TOKEN`

## VAULT_ROLE_ID
*Required*
example: `a1bg13234-1234-ab12-a1b2-1a2d5y9s6g5s`

This is the role ID that is used to fetch the variables from vault.stanford.edu on builds or when running `npm run vault`

## VAULT_SECRET_ID
*Required*
example: `a1bg13234-1234-ab12-a1b2-1a2d5y9s6g5s`

This is the secret ID that is used to fetch the variables from vault.stanford.edu on builds or when running `npm run vault`

## VAULT_OVERWRITE
*Optional*
*Values:* true | false

When pulling values from vault, set to true to overwrite the values you have in your `.env` file and set to false to only add new items.

## LOCAL_STORYBLOK_ACCESS_TOKEN
*Optional*
example: `a456d8asd6f8asdfas5afs64`

An optional override variable for local development. When running `npm run vault` this value replaces the value in `STORYBLOK_ACCESS_TOKEN`. You should set this to a `preview` token for the Storyblok Space.

## NEXT_PUBLIC_STORYBLOK_ACCESS_TOKEN
*Required*
example: `a456d8asd6f8asdfas5afs64`

This is a public token that gets compiled into the front end build. This should be a `public` scoped token of the Storyblok space.

## PRODUCTION_STORYBLOK_ACCESS_TOKEN
*Required*
example: `a456d8asd6f8asdfas5afs64`

This token is used in production Netlify environment builds. This should be set to your production Storyblok Space and ideally using a `public` scoped token to ensure no draft content ever makes it to production builds.

## STORYBLOK_ACCESS_TOKEN
*Required*
example: `a456d8asd6f8asdfas5afs64`

This is the default Storyblok API access token. This can be set as a `preview` scoped token and is used in all non-production environments.

## STORYBLOK_PREVIEW_EDITOR_TOKEN
*Required*
example: `a456d8asd6f8asdfas5afs64`

This is the preview editor token and is used to authenticate the Storyblok visual editor. This token needs to match the token in the Storyblok visual editor token in the url parameter. You can manage the visual editor urls in Storyblok and change this token. It must be scoped to a `preview` token in order to work with draft content in the visual editor.

## STORYBLOK_WEBHOOK_SECRET
*Required*
example: `oEk69ba5EBQHtX2`

This secret is used to validate the webhook calls from Storyblok. At the time of writing this documentation it is used in the background function that delegates builds.

## NETLIFY_DEPLOY_HOOK_ID
*Required*
example: `7896as5fa5sdf8as9df4as7a`

This is the webhook id of a netlify build webhook for the branch/environment to build. You can find it on the end of the URL `https://api.netlify.com/build_hooks/{ID HERE}` and is created in the deploy configuration in Netlify.

## SLACK_WEBHOOK
*Optional*
example: `https://hooks.slack.com/services/AB6A8DF7F/B9D89SD8D/0AD89S8F97SD8F7S8FS8DF7A`

The Slack channel webhook to post to for build notifications.

## STORYBLOK_SLUG_PREFIX
*Optional*
default: `tour`

The `slug` of the content directory in the Storyblok space. No trailing space. This is used to strip and append the slug in the application where needed. The slug will be stripped when creating links across the front end of the website and is added when fetching content from the Storyblok API.

254 changes: 254 additions & 0 deletions netlify/functions/storyblok-background.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import dotenv from 'dotenv';
import { type Config } from '@netlify/functions';
import { createHmac } from 'node:crypto';
import StoryblokClient, { ISbStoryParams } from 'storyblok-js-client';

// LOAD ENV VARIABLES.
// ---------------------------------------------------
dotenv.config();

// NETLIFY FUNCTION CONFIG.
// ---------------------------------------------------
export const config: Config = {
path: '/webhook/storyblok',
};

// HELPER FUNCTIONS.
// ---------------------------------------------------

/**
* Trigger a Netlify build using a build hook.
*
* @param hookID The id of the hook.
* @throws Error if the build could not be triggered.
* @returns void
*/
const triggerNetlifyBuild = async (hookID) => {
console.log('Triggering deploy for:', hookID);
const deployUrl = `https://api.netlify.com/build_hooks/${hookID}`;
const res = await fetch(deployUrl, {
method: 'POST',
body: JSON.stringify({}),
});

if (!res.ok) {
throw new Error('Failed to trigger deploy');
}

console.log('Deploy triggered for:', hookID);
}

/**
* Pings a Slack webhook with a message.
*
* @param message the message
* @returns void
* @throws Error if the Slack webhook could not be pinged.
*/
const pingSlack = async (message: string) => {
const slackWebhook = process.env.SLACK_WEBHOOK ?? '';
const URL = process.env.DEPLOY_PRIME_URL || process.env.URL;
const ENV = process.env.CONTEXT || process.env.SITE_NAME;

if (!slackWebhook) {
console.error('No Slack webhook');
return;
}

try {
const res = await fetch(slackWebhook, {
method: 'POST',
body: JSON.stringify(
{
text: message,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: message,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Environment:* ${ENV}`,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*URL:* ${URL}`,
},
}
],
}
),
});

if (!res.ok) {
console.error('Failed to ping Slack');
}
} catch (error) {
console.error('Failed to ping Slack:', error);
}
}

/**
* Fetches a story from Storyblok.
*
* @param storyID the story ID
* @returns the story object
* @throws Error if the story could not be fetched
*/
const fetchStory = async (storyID: string) => {
console.log('Fetching story:', storyID);
const context = process.env.CONTEXT;
let sbToken;
if (context === 'production') {
sbToken = process.env.PRODUCTION_STORYBLOK_ACCESS_TOKEN || process.env.STORYBLOK_ACCESS_TOKEN;
} else {
sbToken = process.env.STORYBLOK_ACCESS_TOKEN;
}
const sbConfig = {
accessToken: sbToken,
region: 'us'
};
const sbParams = {
version: 'draft' as ISbStoryParams['version'],
cv: Date.now(),
by_ids: storyID
}
const sbClient = new StoryblokClient(sbConfig);
const { data } = await sbClient.getStories(sbParams);

if (data && data.stories && data.stories.length) {
console.log('Story fetched:', data.stories[0].full_slug);
return data.stories[0];
}

throw new Error('Story not found');
}

/**
* Validates the request.
*
* @param req the request
* @param netlifyHookID the Netlify hook id
* @param webhookSecret the webhook secret
* @returns true if the request is valid
* @throws Error if the request is invalid
*/
const validateRequest = async (req: Request, netlifyHookID:string, webhookSecret: string) => {
console.log('Validating request');
const signature = req.headers.get('webhook-signature') ?? '';

if (!signature) {
throw new Error('No signature');
}

if (!netlifyHookID) {
throw new Error('Missing deploy info');
}

// Clone the req so we can read the body elsewhere.
const reqClone = req.clone();
const rawData = await reqClone.text();

if (!rawData) {
throw new Error('No payload');
}

const generatedSignature = createHmac('sha1', webhookSecret).update(rawData).digest('hex');

if (signature !== generatedSignature) {
throw new Error('Wrong signature');
}
console.log('Request validated');
return true;
}

// NETLIFY FUNCTION HANDLER.
// ---------------------------------------------------
export default async (req: Request) => {
console.log('++++++++ START WEBHOOK ++++++++');
const slugPrefix = process.env.STORYBLOK_SLUG_PREFIX ?? 'momentum';
const netlifyHookID = process.env.NETLIFY_DEPLOY_HOOK_ID ?? '';
const webhookSecret = process.env.STORYBLOK_WEBHOOK_SECRET ?? '';

// VALIDATE THE REQUEST AND PAYLOAD.
// ---------------------------------------------------
try {
await validateRequest(req, netlifyHookID, webhookSecret);
}
catch (error) {
console.error('Error:', error);
return;
}

// REQUEST AND PAYLOAD OK.
// ---------------------------------------------------

// Check the payload for the action and for the full_slug.
// If the full slug starts with with the configured slug prefix trigger a netlify build.
// Otherwise do nothing.
// For everything else trigger a build.

// Get the payload.
let payload;
try {
console.log('Parsing payload');
payload = await req.json();
}
catch (error) {
console.error('Error:', error);
await pingSlack(`Failed to parse payload: ${error}`);
return;
}

// Trigger a build if the action and full_slug meet the slug prefix.
// This handles the publish and unpublish actions for stories.
if (payload.action && payload.full_slug && payload.full_slug.length) {
console.log('Handling published/unpublished story');
if (!payload.full_slug.startsWith(slugPrefix)) {
console.log('No build needed');
return;
}
}

// The moved action doesn't include the full_slug so we need to fetch the story to get it.
// This handles the moved action for stories.
if (payload.action === 'moved' && payload.story_id) {
console.log('Handling moved story');
const { story_id } = payload;
let story;
try {
story = await fetchStory(story_id);
} catch (error) {
console.error('Error:', error);
await pingSlack(`Failed to fetch story: ${error}`);
return;
}
const { full_slug } = story;
if (!full_slug || !full_slug.startsWith(slugPrefix)) {
console.log('No build needed');
return;
}
}

// For everything else trigger a build.
// Trigger a build.
try {
await triggerNetlifyBuild(netlifyHookID);
}
catch (error) {
console.error('Error:', error);
await pingSlack(`Failed to trigger build: ${error}`);
}

console.log('++++++++ END WEBHOOK ++++++++');
};


Loading

0 comments on commit 0776970

Please sign in to comment.