-
Notifications
You must be signed in to change notification settings - Fork 402
Feat: edge handlers deploy #1244
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
Changes from all commits
1a5f324
0611298
4f73001
3b44c48
b4fdb36
60dd709
d49400d
6d50343
77326d6
40371d4
031e465
096ff71
19af283
f057acf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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') | ||
|
@@ -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') | ||
const { deployEdgeHandlers } = require('../utils/edge-handlers') | ||
|
||
const DEFAULT_DEPLOY_TIMEOUT = 1.2e6 | ||
|
||
|
@@ -136,6 +134,7 @@ const validateFolders = async ({ deployFolder, functionsFolder, error, log }) => | |
const runDeploy = async ({ | ||
flags, | ||
deployToProduction, | ||
site, | ||
siteData, | ||
api, | ||
siteId, | ||
|
@@ -170,15 +169,28 @@ const runDeploy = async ({ | |
log('Deploying to draft URL...') | ||
} | ||
|
||
const draft = !deployToProduction && !alias | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We first create the deploy, then upload edge handlers and finally let |
||
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) { | ||
|
@@ -212,7 +224,6 @@ const runDeploy = async ({ | |
const logsUrl = `${get(results, 'deploy.admin_url')}/deploys/${get(results, 'deploy.id')}` | ||
|
||
return { | ||
name: results.deploy.deployId, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
siteId: results.deploy.site_id, | ||
siteName: results.deploy.name, | ||
deployId: results.deployId, | ||
|
@@ -359,6 +370,7 @@ class DeployCommand extends Command { | |
const results = await runDeploy({ | ||
flags, | ||
deployToProduction, | ||
site, | ||
siteData, | ||
api, | ||
siteId, | ||
|
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 } |
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 } |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice touch :) |
||
spinner.stopAndPersist({ | ||
text, | ||
symbol, | ||
}) | ||
} | ||
|
||
module.exports = { startSpinner, stopSpinner } |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
@@ -179,21 +197,7 @@ class BaseCommand extends Command { | |
* @return {[string, string]} - tokenValue & location of resolved Netlify API token | ||
*/ | ||
getConfigToken(tokenFromFlag) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
@@ -291,4 +295,5 @@ BaseCommand.flags = { | |
}), | ||
} | ||
|
||
BaseCommand.getToken = getToken | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Used in the tests |
||
module.exports = BaseCommand |
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 }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This cleanup can also be moved to the main |
||
} catch (e) { | ||
warn(`Failed canceling deploy with id ${deployId}: ${e.message}`) | ||
} | ||
// no need to report the error again | ||
error('') | ||
} | ||
} | ||
} | ||
|
||
module.exports = { deployEdgeHandlers } |
There was a problem hiding this comment.
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
withfs
.Moved those to a shared lib