diff --git a/src/commands/deploy.js b/src/commands/deploy.js index c6b57386c97..ace4193938f 100644 --- a/src/commands/deploy.js +++ b/src/commands/deploy.js @@ -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 + 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, 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, diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 00000000000..c799c1650f2 --- /dev/null +++ b/src/lib/api.js @@ -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 } diff --git a/src/lib/fs.js b/src/lib/fs.js new file mode 100644 index 00000000000..4b8f88676bc --- /dev/null +++ b/src/lib/fs.js @@ -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 } diff --git a/src/lib/spinner.js b/src/lib/spinner.js new file mode 100644 index 00000000000..cc963e43200 --- /dev/null +++ b/src/lib/spinner.js @@ -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 + spinner.stopAndPersist({ + text, + symbol, + }) +} + +module.exports = { startSpinner, stopSpinner } diff --git a/src/utils/command.js b/src/utils/command.js index 7bd846d3b3c..ce739313a0a 100644 --- a/src/utils/command.js +++ b/src/utils/command.js @@ -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) { - // 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 module.exports = BaseCommand diff --git a/src/utils/edge-handlers.js b/src/utils/edge-handlers.js new file mode 100644 index 00000000000..5ca753d1902 --- /dev/null +++ b/src/utils/edge-handlers.js @@ -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()) { + 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 }) + } catch (e) { + warn(`Failed canceling deploy with id ${deployId}: ${e.message}`) + } + // no need to report the error again + error('') + } + } +} + +module.exports = { deployEdgeHandlers } diff --git a/src/utils/env.js b/src/utils/env.js index 4f543b034ac..4001e8f0164 100644 --- a/src/utils/env.js +++ b/src/utils/env.js @@ -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') @@ -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 } diff --git a/src/utils/gitignore.js b/src/utils/gitignore.js index 76bc79ef5a6..fabf2b57ee8 100644 --- a/src/utils/gitignore.js +++ b/src/utils/gitignore.js @@ -1,22 +1,11 @@ const path = require('path') -const fs = require('fs') const parseIgnore = require('parse-gitignore') -const { promisify } = require('util') -const readFile = promisify(fs.readFile) -const writeFile = promisify(fs.writeFile) - -function fileExists(filePath) { - return new Promise((resolve, reject) => { - fs.access(filePath, fs.F_OK, err => { - if (err) return resolve(false) - return resolve(true) - }) - }) -} + +const { readFileAsync, writeFileAsync, fileExistsAsync } = require('../lib/fs') async function hasGitIgnore(dir) { const gitIgnorePath = path.join(dir, '.gitignore') - const hasIgnore = await fileExists(gitIgnorePath) + const hasIgnore = await fileExistsAsync(gitIgnorePath) return hasIgnore } @@ -89,14 +78,14 @@ async function ensureNetlifyIgnore(dir) { /* No .gitignore file. Create one and ignore .netlify folder */ if (!(await hasGitIgnore(dir))) { - await writeFile(gitIgnorePath, ignoreContent, 'utf8') + await writeFileAsync(gitIgnorePath, ignoreContent, 'utf8') return false } let gitIgnoreContents let ignorePatterns try { - gitIgnoreContents = await readFile(gitIgnorePath, 'utf8') + gitIgnoreContents = await readFileAsync(gitIgnorePath, 'utf8') ignorePatterns = parseIgnore.parse(gitIgnoreContents) } catch (e) { // ignore @@ -104,7 +93,7 @@ async function ensureNetlifyIgnore(dir) { /* Not ignoring .netlify folder. Add to .gitignore */ if (!ignorePatterns || !ignorePatterns.patterns.includes('.netlify')) { const newContents = `${gitIgnoreContents}\n${ignoreContent}` - await writeFile(gitIgnorePath, newContents, 'utf8') + await writeFileAsync(gitIgnorePath, newContents, 'utf8') } } diff --git a/tests/command.addons.test.js b/tests/command.addons.test.js index 98aff327565..cd48a4f242d 100644 --- a/tests/command.addons.test.js +++ b/tests/command.addons.test.js @@ -1,41 +1,17 @@ const test = require('ava') const { createSiteBuilder } = require('./utils/siteBuilder') const callCli = require('./utils/callCli') -const createLiveTestSite = require('./utils/createLiveTestSite') +const { generateSiteName, createLiveTestSite } = require('./utils/createLiveTestSite') -const siteName = - 'netlify-test-addons-' + - Math.random() - .toString(36) - .replace(/[^a-z]+/g, '') - .substr(0, 8) - -async function listAccounts() { - return JSON.parse(await callCli(['api', 'listAccountsForUser'])) -} +const siteName = generateSiteName('netlify-test-addons-') if (process.env.IS_FORK !== 'true') { test.before(async t => { - const accounts = await listAccounts() - t.is(Array.isArray(accounts), true) - t.truthy(accounts.length) - - const account = accounts[0] - + const siteId = await createLiveTestSite(siteName) const builder = createSiteBuilder({ siteName: 'site-with-addons' }) await builder.buildAsync() - const execOptions = { - cwd: builder.directory, - windowsHide: true, - windowsVerbatimArguments: true, - } - - console.log('creating new site for tests: ' + siteName) - const siteId = await createLiveTestSite(siteName, account.slug, execOptions) - t.truthy(siteId != null) - - t.context.execOptions = { ...execOptions, env: { ...process.env, NETLIFY_SITE_ID: siteId } } + t.context.execOptions = { cwd: builder.directory, env: { NETLIFY_SITE_ID: siteId } } t.context.builder = builder }) diff --git a/tests/command.deploy.test.js b/tests/command.deploy.test.js new file mode 100644 index 00000000000..b713b41161f --- /dev/null +++ b/tests/command.deploy.test.js @@ -0,0 +1,137 @@ +const test = require('ava') +const { getToken } = require('../src/utils/command') +const fetch = require('node-fetch') +const { withSiteBuilder } = require('./utils/siteBuilder') +const callCli = require('./utils/callCli') +const { generateSiteName, createLiveTestSite } = require('./utils/createLiveTestSite') + +const siteName = generateSiteName('netlify-test-deploy-') + +const validateDeploy = async ({ deploy, siteName, content, t }) => { + t.truthy(deploy.site_name) + t.truthy(deploy.deploy_url) + t.truthy(deploy.deploy_id) + t.truthy(deploy.logs) + t.is(deploy.site_name, siteName) + + const actualContent = await fetch(deploy.deploy_url) + .then(r => r.text()) + .catch(() => undefined) + + t.is(actualContent, content) +} + +if (process.env.IS_FORK !== 'true') { + test.before(async t => { + const siteId = await createLiveTestSite(siteName) + t.context.siteId = siteId + }) + + test.serial('should deploy site when dir flag is passed', async t => { + await withSiteBuilder('site-with-public-folder', async builder => { + const content = '