Skip to content

Commit

Permalink
Website: Add admin page to manage the Fleet Sandbox waitlist (#13111)
Browse files Browse the repository at this point in the history
Closes: #12954

Changes:
- Added an admin page that displays a table containing all of the users
that are currently on the Fleet Sandbox waitlist where admins can
approve waitlisted users.
- Added a new email template that tells users that their Fleet Sandbox
instance is ready.
- Added a new action:
`admin/provision-sandbox-instance-and-deliver-email.js`, an action that
provisions a Fleet sandbox instance for a single user and sends them an
email telling them that their Fleet Sandbox Instance is ready.
- Added a script that provisions a Fleet Sandbox instance for the user
who has been on the waitlist the longest and sends them an email telling
them that their Sandbox instance is ready.
  • Loading branch information
eashaw authored Aug 4, 2023
1 parent f7296de commit 46802ee
Show file tree
Hide file tree
Showing 20 changed files with 480 additions and 69 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
module.exports = {


friendlyName: 'Provision sandbox instance and deliver email',


description: 'Provisions a Fleet sandbox for a user and delivers an email to a user letting them know their Fleet Sandbox instance is ready.',


inputs: {
userId: {
type: 'number',
description: 'The database ID of the user who is currently on the Fleet Sandbox waitlist',
required: true
}
},


exits: {
success: {
description: 'A user was successfully removed from the Fleet Sandbox waitlist.'
},
},


fn: async function ({userId}) {

let userToRemoveFromSandboxWaitlist = await User.findOne({id: userId});

if(!userToRemoveFromSandboxWaitlist.inSandboxWaitlist) {
throw new Error(`When attempting to provision a Fleet Sandbox instance for a user (id:${userId}) who is on the waitlist, the user record associated with the provided ID has already been removed from the waitlist.`);
}

let sandboxInstanceDetails = await sails.helpers.fleetSandboxCloudProvisioner.provisionNewFleetSandboxInstance.with({
firstName: userToRemoveFromSandboxWaitlist.firstName,
lastName: userToRemoveFromSandboxWaitlist.lastName,
emailAddress: userToRemoveFromSandboxWaitlist.emailAddress,
})
.intercept((err)=>{
return new Error(`When attempting to provision a new Fleet Sandbox instance for a User (id:${userToRemoveFromSandboxWaitlist.id}), an error occured. Full error: ${err}`);
});

await User.updateOne({id: userId}).set({
fleetSandboxURL: sandboxInstanceDetails.fleetSandboxURL,
fleetSandboxExpiresAt: sandboxInstanceDetails.fleetSandboxExpiresAt,
fleetSandboxDemoKey: sandboxInstanceDetails.fleetSandboxDemoKey,
inSandboxWaitlist: false,
});

// Send the user an email to let them know that their Fleet sandbox instance is ready.
await sails.helpers.sendTemplateEmail.with({
to: userToRemoveFromSandboxWaitlist.emailAddress,
from: sails.config.custom.fromEmailAddress,
fromName: sails.config.custom.fromName,
subject: 'Your Fleet Sandbox instance is ready!',
template: 'email-sandbox-ready-approved',
templateData: {},
});

// All done.
return;

}


};
4 changes: 4 additions & 0 deletions website/api/controllers/admin/view-email-template-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ module.exports = {
layout = 'layout-email';
fakeData = {};
break;
case 'email-sandbox-ready-approved':
layout = 'layout-email';
fakeData = {};
break;
default:
layout = 'layout-email-newsletter';
fakeData = {
Expand Down
31 changes: 31 additions & 0 deletions website/api/controllers/admin/view-sandbox-waitlist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module.exports = {


friendlyName: 'View sandbox waitlist',


description: 'Display "Sandbox waitlist" page.',


exits: {

success: {
viewTemplatePath: 'pages/admin/sandbox-waitlist'
}

},


fn: async function () {

let usersCurrentlyOnWaitlist = await User.find({inSandboxWaitlist: true})
.sort('createdAt ASC');

return {
usersWaitingForSandboxInstance: usersCurrentlyOnWaitlist
};

}


};
72 changes: 14 additions & 58 deletions website/api/controllers/entrance/signup.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ the account verification message.)`,
'parameters should have been validated/coerced _before_ they were sent.'
},

requestToSandboxTimedOut: {
requestToProvisionerTimedOut: {
statusCode: 408,
description: 'The request to the cloud provisioner exceeded the set timeout.',
},
Expand Down Expand Up @@ -151,63 +151,19 @@ the account verification message.)`,
// If the Fleet Sandbox waitlist is not enabled (sails.config.custom.fleetSandboxWaitlistEnabled) We'll provision a Sandbox instance BEFORE creating the new User record.
// This way, if this fails, we won't save the new record to the database, and the user will see an error on the signup form asking them to try again.

const FIVE_DAYS_IN_MS = (5*24*60*60*1000);
// Creating an expiration JS timestamp for the Fleet sandbox instance. NOTE: We send this value to the cloud provisioner API as an ISO 8601 string.
let fleetSandboxExpiresAt = Date.now() + FIVE_DAYS_IN_MS;

// Creating a fleetSandboxDemoKey, this will be used for the user's password when we log them into their Sandbox instance.
let fleetSandboxDemoKey = await sails.helpers.strings.uuid();

// Send a POST request to the cloud provisioner API
let cloudProvisionerResponseData = await sails.helpers.http.post(
'https://sandbox.fleetdm.com/new',
{ // Request body
'name': firstName + ' ' + lastName,
'email': newEmailAddress,
'password': fleetSandboxDemoKey, //« this provisioner API was originally designed to accept passwords, but rather than specifying the real plaintext password, since users always access Fleet Sandbox from their fleetdm.com account anyway, this generated demo key is used instead to avoid any confusion
'sandbox_expiration': new Date(fleetSandboxExpiresAt).toISOString(), // sending expiration_timestamp as an ISO string.
},
{ // Request headers
'Authorization':sails.config.custom.cloudProvisionerSecret
}
)
.timeout(10000)// FUTURE: set this timeout to be 5000ms
.intercept(['requestFailed', 'non200Response'], (err)=>{
// If we received a non-200 response from the cloud provisioner API, we'll throw a 500 error.
return new Error('When attempting to provision a new user who just signed up ('+emailAddress+'), the cloud provisioner gave a non 200 response. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Raw response received from provisioner: '+err.stack);
let sandboxInstanceDetails = await sails.helpers.fleetSandboxCloudProvisioner.provisionNewFleetSandboxInstance.with({
firstName: firstName,
lastName: lastName,
emailAddress: newEmailAddress,
})
.intercept({name: 'TimeoutError'},(err)=>{
// If the request timed out, log a warning and return a 'requestToSandboxTimedOut' response.
sails.log.warn('When attempting to provision a new user who just signed up ('+emailAddress+'), the request to the cloud provisioner took over timed out. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Raw error: '+err.stack);
return 'requestToSandboxTimedOut';
.intercept('requestToProvisionerTimedOut', (err)=>{ // If the request to the Fleet Sandbox provisioner fails, we'll log a warning an return a requestToSandboxTimedOut response. This will tell the frontend to display a message asking the user to retry signing up.
sails.log.warn(`When attempting to provision a new Fleet Sandbox instance for a new user signing up (email: ${newEmailAddress}). The Fleet Sandbox provisioner returned a non 200 response. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Full error: ${err}`);
return 'requestToProvisionerTimedOut';
})
.intercept((err)=>{ // For any other errors, we'll throw a 500 error.
return new Error(`When attempting to provision a new Fleet Sandbox instance for a new user signing up (email: ${newEmailAddress}), an error occured. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Full error: ${err}`);
});

if(!cloudProvisionerResponseData.URL) {
// If we didn't receive a URL in the response from the cloud provisioner API, we'll throwing an error before we save the new user record and the user will need to try to sign up again.
throw new Error(
`When provisioning a Fleet Sandbox instance for a new user who just signed up (${emailAddress}), the response data from the cloud provisioner API was malformed. It did not contain a valid Fleet Sandbox instance URL in its expected "URL" property.
The incomplete user record has not been saved in the database, and the user will be asked to try signing up again.
Here is the malformed response data (parsed response body) from the cloud provisioner API: ${cloudProvisionerResponseData}`
);
}

// If "Try Fleet Sandbox" was provided as the signupReason, we'll make sure their Sandbox instance is live before we continue.
if(signupReason === 'Try Fleet Sandbox') {
// Start polling the /healthz endpoint of the created Fleet Sandbox instance, once it returns a 200 response, we'll continue.
await sails.helpers.flow.until( async()=>{
let healthCheckResponse = await sails.helpers.http.sendHttpRequest('GET', cloudProvisionerResponseData.URL+'/healthz')
.timeout(5000)
.tolerate('non200Response')
.tolerate('requestFailed')
.tolerate({name: 'TimeoutError'});
if(healthCheckResponse) {
return true;
}
}, 10000).intercept('tookTooLong', ()=>{
return new Error('This newly provisioned Fleet Sandbox instance (for '+emailAddress+') is taking too long to respond with a 2xx status code, even after repeatedly polling the health check endpoint. Note that failed requests and non-2xx responses from the health check endpoint were ignored during polling. Search for a bit of non-dynamic text from this error message in the fleetdm.com source code for more info on exactly how this polling works.');
});
}

// Build up data for the new user record and save it to the database.
// (Also use `fetch` to retrieve the new ID so that we can use it below.)
newUserRecord = await User.create(_.extend({
Expand All @@ -217,9 +173,9 @@ the account verification message.)`,
emailAddress: newEmailAddress,
signupReason,
password: await sails.helpers.passwords.hashPassword(password),
fleetSandboxURL: cloudProvisionerResponseData.URL,
fleetSandboxExpiresAt,
fleetSandboxDemoKey,
fleetSandboxURL: sandboxInstanceDetails.fleetSandboxURL,
fleetSandboxExpiresAt: sandboxInstanceDetails.fleetSandboxExpiresAt,
fleetSandboxDemoKey: sandboxInstanceDetails.fleetSandboxDemoKey,
stripeCustomerId,
inSandboxWaitlist: false,
tosAcceptedByIp: this.req.ip
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
module.exports = {


friendlyName: 'Provision new Fleet Sandbox instance',


description: 'Provisions a new Fleet Sandbox instance and returns the details of the Sandbox instance.',


inputs: {

firstName: {
type: 'string',
required: true,
description: 'The first name of the user who is having a Fleet Sandbox instance provisioned for them.',
extendedDescription: 'This will be used in the Fleet instance'
},
lastName: {
type: 'string',
required: true,
description: 'The last name of the user who is having a Fleet Sandbox instance provisioned for them.',
extendedDescription: 'This will be used in the Fleet instance'
},
emailAddress: {
type: 'string',
required: true,
description: 'The email address of the User record that is having a Fleet sandbox instance provisioned for them.',
extendedDescription: 'This will be used in the Fleet instance'
},

},


exits: {

success: {
description: 'All done.',
outputFriendlyName: 'Sandbox instance details',
outputType: {
fleetSandboxDemoKey: 'string',
fleetSandboxExpiresAt: 'number',
fleetSandboxURL: 'string',
},
},

requestToProvisionerTimedOut: {
description: 'The request to the Fleet Sandbox provisioner exceeded the set timeout.',
},
},


fn: async function ({firstName, lastName, emailAddress}) {

const FIVE_DAYS_IN_MS = (5*24*60*60*1000);
// Creating an expiration JS timestamp for the Fleet sandbox instance. NOTE: We send this value to the cloud provisioner API as an ISO 8601 string.
let fleetSandboxExpiresAt = Date.now() + FIVE_DAYS_IN_MS;

// Creating a fleetSandboxDemoKey, this will be used for the user's password when we log them into their Sandbox instance.
let fleetSandboxDemoKey = await sails.helpers.strings.uuid();

// Send a POST request to the cloud provisioner API
let cloudProvisionerResponseData = await sails.helpers.http.post.with({
url: 'https://sandbox.fleetdm.com/new',
data: {
'name': firstName + ' ' + lastName,
'email': emailAddress,
'password': fleetSandboxDemoKey, //« this provisioner API was originally designed to accept passwords, but rather than specifying the real plaintext password, since users always access Fleet Sandbox from their fleetdm.com account anyway, this generated demo key is used instead to avoid any confusion
'sandbox_expiration': new Date(fleetSandboxExpiresAt).toISOString(), // sending expiration_timestamp as an ISO string.
},
headers: {
'Authorization':sails.config.custom.cloudProvisionerSecret
}
})
.timeout(10000)
.intercept(['requestFailed', 'non200Response'], (err)=>{
// If we received a non-200 response from the cloud provisioner API, we'll throw a 500 error.
return new Error('When attempting to provision a Sandbox instance for a user on the Fleet Sandbox waitlist ('+emailAddress+'), the cloud provisioner gave a non 200 response. Raw response received from provisioner: '+err.stack);
})
.intercept({name: 'TimeoutError'},()=>{
// If the request timed out, log a warning and return a 'requestToSandboxTimedOut' response.
return 'requestToProvisionerTimedOut';
});

if(!cloudProvisionerResponseData.URL) {
// If we didn't receive a URL in the response from the cloud provisioner API, we'll throw an error before we save the new user record and the user will need to try to sign up again.
throw new Error(
`The response data from the cloud provisioner API was malformed. It did not contain a valid Fleet Sandbox instance URL in its expected "URL" property.
Here is the malformed response data (parsed response body) from the cloud provisioner API: ${cloudProvisionerResponseData}`
);
}

// Start polling the /healthz endpoint of the created Fleet Sandbox instance, once it returns a 200 response, we'll continue.
await sails.helpers.flow.until( async()=>{
let healthCheckResponse = await sails.helpers.http.sendHttpRequest('GET', cloudProvisionerResponseData.URL+'/healthz')
.timeout(5000)
.tolerate('non200Response')
.tolerate('requestFailed')
.tolerate({name: 'TimeoutError'});
if(healthCheckResponse) {
return true;
}
}, 10000)//∞
.intercept('tookTooLong', ()=>{
return new Error('This newly provisioned Fleet Sandbox instance (for '+emailAddress+') is taking too long to respond with a 2xx status code, even after repeatedly polling the health check endpoint. Note that failed requests and non-2xx responses from the health check endpoint were ignored during polling. Search for a bit of non-dynamic text from this error message in the fleetdm.com source code for more info on exactly how this polling works.');
});

return {
fleetSandboxDemoKey,
fleetSandboxExpiresAt,
fleetSandboxURL: cloudProvisionerResponseData.URL,
};

}


};

Loading

0 comments on commit 46802ee

Please sign in to comment.