Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: edge handlers deploy #1244

Merged
merged 14 commits into from
Sep 21, 2020
30 changes: 21 additions & 9 deletions src/commands/deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ const path = require('path')
const chalk = require('chalk')
const { flags } = require('@oclif/command')
const get = require('lodash.get')
const fs = require('fs')
const { promisify } = require('util')
const prettyjson = require('prettyjson')
const ora = require('ora')
const logSymbols = require('log-symbols')
Expand All @@ -16,8 +14,8 @@ const isObject = require('lodash.isobject')
const SitesCreateCommand = require('./sites/create')
const LinkCommand = require('./link')
const { NETLIFYDEV, NETLIFYDEVLOG, NETLIFYDEVERR } = require('../utils/logo')

const statAsync = promisify(fs.stat)
const { statAsync } = require('../lib/fs')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a few places in the code that we used promisify with fs.
Moved those to a shared lib

const { deployEdgeHandlers } = require('../utils/edge-handlers')

const DEFAULT_DEPLOY_TIMEOUT = 1.2e6

Expand Down Expand Up @@ -136,6 +134,7 @@ const validateFolders = async ({ deployFolder, functionsFolder, error, log }) =>
const runDeploy = async ({
flags,
deployToProduction,
site,
siteData,
api,
siteId,
Expand Down Expand Up @@ -170,15 +169,28 @@ const runDeploy = async ({
log('Deploying to draft URL...')
}

const draft = !deployToProduction && !alias
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We first create the deploy, then upload edge handlers and finally let js-client finalise the deploy and upload files and functions.

const title = flags.message
results = await api.createSiteDeploy({ siteId, title, body: { draft, branch: alias } })
const deployId = results.id

const silent = flags.json || flags.silent
await deployEdgeHandlers({
site,
deployId,
api,
silent,
error,
warn,
})
results = await api.deploy(siteId, deployFolder, {
configPath,
fnDir: functionsFolder,
statusCb: flags.json || flags.silent ? () => {} : deployProgressCb(),
draft: !deployToProduction && !alias,
message: flags.message,
statusCb: silent ? () => {} : deployProgressCb(),
deployTimeout: flags.timeout * 1000 || DEFAULT_DEPLOY_TIMEOUT,
syncFileLimit: 100,
branch: alias,
// pass an existing deployId to update
deployId,
})
} catch (e) {
switch (true) {
Expand Down Expand Up @@ -212,7 +224,6 @@ const runDeploy = async ({
const logsUrl = `${get(results, 'deploy.admin_url')}/deploys/${get(results, 'deploy.id')}`

return {
name: results.deploy.deployId,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

results.deploy.deployId is always undefined, this should be either results.deploy.id or results.deployId.
Since name was never printed, and deployId is already part of the result, I chose to remove the line entirely.

siteId: results.deploy.site_id,
siteName: results.deploy.name,
deployId: results.deployId,
Expand Down Expand Up @@ -359,6 +370,7 @@ class DeployCommand extends Command {
const results = await runDeploy({
flags,
deployToProduction,
site,
siteData,
api,
siteId,
Expand Down
78 changes: 78 additions & 0 deletions src/lib/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// This file should be used to wrap API methods that are not part of our open API spec yet
// Once they become part of the spec, js-client should be used
const fetch = require('node-fetch')

const getHeaders = ({ token }) => {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
}
}

const getErrorMessage = async ({ response }) => {
const contentType = response.headers.get('content-type')
if (contentType && contentType.indexOf('application/json') !== -1) {
const json = await response.json()
return json.message
} else {
const text = await response.text()
return text
}
}

const checkResponse = async ({ response }) => {
if (!response.ok) {
const message = await getErrorMessage({ response }).catch(() => undefined)
const errorPostfix = message && message ? ` and message '${message}'` : ''
throw new Error(`Request failed with status '${response.status}'${errorPostfix}`)
}
}

const getApiUrl = ({ api }) => {
return `${api.scheme}://${api.host}${api.pathPrefix}`
}

const apiPost = async ({ api, path, data }) => {
const apiUrl = getApiUrl({ api })
const response = await fetch(`${apiUrl}/${path}`, {
method: 'POST',
body: JSON.stringify(data),
headers: getHeaders({ token: api.accessToken }),
agent: api.agent,
})

await checkResponse({ response })

return response
}

const uploadEdgeHandlers = async ({ api, deployId, bundleBuffer, manifest }) => {
const response = await apiPost({ api, path: `deploys/${deployId}/edge_handlers`, data: manifest })
const { error, exists, upload_url: uploadUrl } = await response.json()
if (error) {
throw new Error(error)
}

if (exists) {
return false
}

if (!uploadUrl) {
throw new Error('Missing upload URL')
}

const putResponse = await fetch(uploadUrl, {
method: 'PUT',
body: bundleBuffer,
headers: {
'Content-Type': 'application/javascript',
},
agent: api.agent,
})

await checkResponse({ response: putResponse })

return true
}

module.exports = { uploadEdgeHandlers }
26 changes: 26 additions & 0 deletions src/lib/fs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const fs = require('fs')
const { promisify } = require('util')

const statAsync = promisify(fs.stat)
const readFileAsync = promisify(fs.readFile)
const writeFileAsync = promisify(fs.writeFile)
const accessAsync = promisify(fs.access)

const readFileAsyncCatchError = async filepath => {
try {
return { content: await readFileAsync(filepath) }
} catch (error) {
return { error }
}
}

const fileExistsAsync = async filePath => {
try {
await accessAsync(filePath, fs.F_OK)
return true
} catch (_) {
return false
}
}

module.exports = { statAsync, readFileAsync, readFileAsyncCatchError, writeFileAsync, fileExistsAsync }
21 changes: 21 additions & 0 deletions src/lib/spinner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const ora = require('ora')
const logSymbols = require('log-symbols')

const startSpinner = ({ text }) => {
return ora({
text,
}).start()
}

const stopSpinner = ({ spinner, text, error }) => {
if (!spinner) {
return
}
const symbol = error ? logSymbols.error : logSymbols.success
Copy link
Contributor

@ehmicky ehmicky Sep 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice touch :)
(Hiding logSymbols behind an error property)

spinner.stopAndPersist({
text,
symbol,
})
}

module.exports = { startSpinner, stopSpinner }
35 changes: 20 additions & 15 deletions src/utils/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ const { NETLIFY_AUTH_TOKEN, NETLIFY_API_URL } = process.env
// Todo setup client for multiple environments
const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'

const getToken = tokenFromFlag => {
// 1. First honor command flag --auth
if (tokenFromFlag) {
return [tokenFromFlag, 'flag']
}
// 2. then Check ENV var
if (NETLIFY_AUTH_TOKEN && NETLIFY_AUTH_TOKEN !== 'null') {
return [NETLIFY_AUTH_TOKEN, 'env']
}
// 3. If no env var use global user setting
const userId = globalConfig.get('userId')
const tokenFromConfig = globalConfig.get(`users.${userId}.auth.token`)
if (tokenFromConfig) {
return [tokenFromConfig, 'config']
}
return [null, 'not found']
}

class BaseCommand extends Command {
constructor(...args) {
super(...args)
Expand Down Expand Up @@ -179,21 +197,7 @@ class BaseCommand extends Command {
* @return {[string, string]} - tokenValue & location of resolved Netlify API token
*/
getConfigToken(tokenFromFlag) {
Copy link
Contributor Author

@erezrokah erezrokah Sep 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't remove this since it's used in child classes

// 1. First honor command flag --auth
if (tokenFromFlag) {
return [tokenFromFlag, 'flag']
}
// 2. then Check ENV var
if (NETLIFY_AUTH_TOKEN && NETLIFY_AUTH_TOKEN !== 'null') {
return [NETLIFY_AUTH_TOKEN, 'env']
}
// 3. If no env var use global user setting
const userId = globalConfig.get('userId')
const tokenFromConfig = globalConfig.get(`users.${userId}.auth.token`)
if (tokenFromConfig) {
return [tokenFromConfig, 'config']
}
return [null, 'not found']
return getToken(tokenFromFlag)
}

async authenticate(tokenFromFlag) {
Expand Down Expand Up @@ -291,4 +295,5 @@ BaseCommand.flags = {
}),
}

BaseCommand.getToken = getToken
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used in the tests

module.exports = BaseCommand
88 changes: 88 additions & 0 deletions src/utils/edge-handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const path = require('path')
const { statAsync, readFileAsyncCatchError } = require('../lib/fs')
const { uploadEdgeHandlers } = require('../lib/api')
const { startSpinner, stopSpinner } = require('../lib/spinner')

const MANIFEST_FILENAME = 'manifest.json'
const EDGE_HANDLERS_FOLDER = '.netlify/edge-handlers'

const validateEdgeHandlerFolder = async ({ site, error }) => {
try {
const resolvedFolder = path.resolve(site.root, EDGE_HANDLERS_FOLDER)
const stat = await statAsync(resolvedFolder)
if (!stat.isDirectory()) {
ehmicky marked this conversation as resolved.
Show resolved Hide resolved
error(`Edge Handlers folder ${EDGE_HANDLERS_FOLDER} must be a path to a directory`)
}
return resolvedFolder
} catch (e) {
// ignore errors at the moment
// TODO: report error if 'edge_handlers' config exists after
// https://github.com/netlify/build/pull/1829 is published
}
}

const readBundleAndManifest = async ({ edgeHandlersResolvedFolder, error }) => {
const manifestPath = path.resolve(edgeHandlersResolvedFolder, MANIFEST_FILENAME)
const { content: manifest, error: manifestError } = await readFileAsyncCatchError(manifestPath)
if (manifestError) {
error(`Could not read Edge Handlers manifest file ${manifestPath}: ${manifestError.message}`)
}

let manifestJson
try {
manifestJson = JSON.parse(manifest)
} catch (e) {
error(`Edge Handlers manifest file is not a valid JSON file: ${e.message}`)
}

if (!manifestJson.sha) {
error(`Edge Handlers manifest file is missing the 'sha' property`)
}

const bundlePath = path.resolve(edgeHandlersResolvedFolder, manifestJson.sha)
const { content: bundleBuffer, error: bundleError } = await readFileAsyncCatchError(bundlePath)

if (bundleError) {
error(`Could not read Edge Handlers bundle file ${bundlePath}: ${bundleError.message}`)
}

return { bundleBuffer, manifest: manifestJson }
}

const deployEdgeHandlers = async ({ site, deployId, api, silent, error, warn }) => {
const edgeHandlersResolvedFolder = await validateEdgeHandlerFolder({ site, error })
if (edgeHandlersResolvedFolder) {
let spinner
try {
spinner = silent
? null
: startSpinner({ text: `Deploying Edge Handlers from directory ${edgeHandlersResolvedFolder}` })

const { bundleBuffer, manifest } = await readBundleAndManifest({ edgeHandlersResolvedFolder, error })
// returns false if the bundle exists, true on success, throws on error
const success = await uploadEdgeHandlers({
api,
deployId,
bundleBuffer,
manifest,
})

const text = success
? `Finished deploying Edge Handlers from directory: ${edgeHandlersResolvedFolder}`
: `Skipped deploying Edge Handlers since the bundle already exists`
stopSpinner({ spinner, text, error: false })
} catch (e) {
const text = `Failed deploying Edge Handlers: ${e.message}`
stopSpinner({ spinner, text, error: true })
try {
await api.cancelSiteDeploy({ deploy_id: deployId })
Copy link
Contributor Author

@erezrokah erezrokah Sep 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cleanup can also be moved to the main deploy function if that makes more sense

} catch (e) {
warn(`Failed canceling deploy with id ${deployId}: ${e.message}`)
}
// no need to report the error again
error('')
}
}
}

module.exports = { deployEdgeHandlers }
12 changes: 4 additions & 8 deletions src/utils/env.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
const path = require('path')
const fs = require('fs')
const { promisify } = require('util')
const dotenv = require('dotenv')

const fileStat = promisify(fs.stat)
const readFile = promisify(fs.readFile)
const { statAsync, readFileAsync } = require('../lib/fs')

async function getEnvSettings(projectDir) {
const envDevelopmentFile = path.resolve(projectDir, '.env.development')
Expand All @@ -13,17 +9,17 @@ async function getEnvSettings(projectDir) {
const settings = {}

try {
if ((await fileStat(envFile)).isFile()) settings.file = envFile
if ((await statAsync(envFile)).isFile()) settings.file = envFile
} catch (err) {
// nothing
}
try {
if ((await fileStat(envDevelopmentFile)).isFile()) settings.file = envDevelopmentFile
if ((await statAsync(envDevelopmentFile)).isFile()) settings.file = envDevelopmentFile
} catch (err) {
// nothing
}

if (settings.file) settings.vars = dotenv.parse(await readFile(settings.file)) || {}
if (settings.file) settings.vars = dotenv.parse(await readFileAsync(settings.file)) || {}

return settings
}
Expand Down
Loading