Skip to content

Commit

Permalink
feat: add d2-app-scripts deploy command (#451)
Browse files Browse the repository at this point in the history
This adds the d2-app-script deploy command. It takes an existing application bundle (created with d2-app-scripts build) and pushes it to a running DHIS2 instance.
  • Loading branch information
amcgee committed Sep 2, 2020
1 parent c6f68f1 commit 655a053
Show file tree
Hide file tree
Showing 10 changed files with 410 additions and 100 deletions.
3 changes: 3 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,20 @@
"@dhis2/app-shell": "5.0.0",
"@dhis2/cli-helpers-engine": "^1.5.0",
"archiver": "^3.1.1",
"axios": "^0.20.0",
"babel-jest": "^24.9.0",
"babel-plugin-react-require": "^3.1.3",
"chokidar": "^3.3.0",
"detect-port": "^1.3.0",
"dotenv": "^8.1.0",
"dotenv-expand": "^5.1.0",
"form-data": "^3.0.0",
"fs-extra": "^8.1.0",
"gaze": "^1.1.3",
"handlebars": "^4.3.3",
"i18next-conv": "^9",
"i18next-scanner": "^2.10.3",
"inquirer": "^7.3.3",
"jest-cli": "^24.9.0",
"lodash": "^4.17.11",
"parse-author": "^2.0.0",
Expand Down
4 changes: 2 additions & 2 deletions cli/src/commands/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const setAppParameters = (standalone, config) => {
}

const handler = async ({
cwd,
cwd = process.cwd(),
mode,
dev,
watch,
Expand Down Expand Up @@ -123,7 +123,7 @@ const handler = async ({
.replace(/{{version}}/, config.version)
reporter.info(
`Creating app archive at ${chalk.bold(
path.relative(process.cwd(), appBundle)
path.relative(cwd, appBundle)
)}...`
)
await bundleApp(paths.buildAppOutput, appBundle)
Expand Down
179 changes: 179 additions & 0 deletions cli/src/commands/deploy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
const { reporter, chalk } = require('@dhis2/cli-helpers-engine')
const path = require('path')
const fs = require('fs-extra')
const FormData = require('form-data')
const inquirer = require('inquirer')

const makePaths = require('../lib/paths')
const parseConfig = require('../lib/parseConfig')
const { createClient } = require('../lib/httpClient')
const { constructAppUrl } = require('../lib/constructAppUrl')

const dumpHttpError = (message, response) => {
if (!response) {
reporter.error(message)
return
}

reporter.error(
message,
response.status,
typeof response.data === 'object'
? response.data.message
: response.statusText
)
reporter.debugErr('Error details', response.data)
}

const promptForDhis2Config = async params => {
if (
process.env.CI &&
(!params.baseUrl || !params.username || !params.password)
) {
reporter.error(
'Prompt disabled in CI mode - missing baseUrl, username, or password parameter.'
)
process.exit(1)
}

const isValidUrl = input =>
input && input.length && input.match(/^https?:\/\/[^/.]+/)

const responses = await inquirer.prompt([
{
type: 'input',
name: 'baseUrl',
message: 'DHIS2 instance URL:',
when: () => !params.baseUrl,
validate: input =>
isValidUrl(input)
? true
: 'Please enter a valid URL, it must start with http:https:// or https://',
},
{
type: 'input',
name: 'username',
message: 'DHIS2 instance username:',
when: () => !params.username,
},
{
type: 'password',
name: 'password',
message: 'DHIS2 instance password:',
when: () => !params.password,
},
])

return {
baseUrl: responses.baseUrl || params.baseUrl,
auth: {
username: responses.username || params.username,
password: responses.password || params.password,
},
}
}

const handler = async ({ cwd = process.cwd(), ...params }) => {
const paths = makePaths(cwd)
const config = parseConfig(paths)

const dhis2Config = await promptForDhis2Config(params)

if (config.standalone) {
reporter.error(`Standalone apps cannot be deployed to DHIS2 instances`)
process.exit(1)
}

const appBundle = path.relative(
cwd,
paths.buildAppBundle
.replace(/{{name}}/, config.name)
.replace(/{{version}}/, config.version)
)

if (!fs.existsSync(appBundle)) {
reporter.error(
`App bundle does not exist, run ${chalk.bold(
'd2-app-scripts build'
)} before deploying.`
)
process.exit(1)
}

const client = createClient(dhis2Config)
const formData = new FormData()
formData.append('file', fs.createReadStream(appBundle))

let serverVersion
try {
reporter.print(`Pinging server ${dhis2Config.baseUrl}...`)
const rawServerVersion = (await client.get('/api/system/info.json'))
.data.version
const parsedServerVersion = /(\d+)\.(\d+)/.exec(rawServerVersion)
if (!parsedServerVersion) {
reporter.error(
`Invalid server version ${rawServerVersion} found, aborting...`
)
process.exit(1)
}
serverVersion = {
full: parsedServerVersion[0],
major: parsedServerVersion[1],
minor: parsedServerVersion[2],
}
reporter.debug(
'Found server version',
serverVersion.full,
`(${rawServerVersion})`
)
} catch (e) {
dumpHttpError(
`Server ${chalk.bold(dhis2Config.baseUrl)} could not be contacted`,
e.response
)
process.exit(1)
}

const appUrl = constructAppUrl(dhis2Config.baseUrl, config, serverVersion)

try {
reporter.print('Uploading app bundle...')
await client.post('/api/apps', formData, {
headers: {
...formData.getHeaders(),
},
timeout: 30000, // Ensure we have enough time to upload a large zip file
})
reporter.info(
`Successfully deployed ${config.name} to ${dhis2Config.baseUrl}`
)
} catch (e) {
dumpHttpError('Failed to upload app, HTTP error', e.response)
process.exit(1)
}

reporter.debug(`Testing app launch url at ${appUrl}...`)
try {
await client.get(appUrl)
} catch (e) {
dumpHttpError(`Uploaded app not responding at ${appUrl}`)
process.exit(1)
}
reporter.print(`App is available at ${appUrl}`)
}

const command = {
command: 'deploy [baseUrl]',
alias: 'd',
desc: 'Deploy the built application to a specific DHIS2 instance',
builder: {
username: {
alias: 'u',
description:
'The username for authenticating with the DHIS2 instance',
},
},
handler,
}

module.exports = command
27 changes: 27 additions & 0 deletions cli/src/lib/constructAppUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module.exports.constructAppUrl = (baseUrl, config, serverVersion) => {
let appUrl = baseUrl

const isModernServer = serverVersion.major >= 2 && serverVersion.minor >= 35

// From core version 2.35, short_name is used instead of the human-readable title to generate the url slug
const urlSafeAppSlug = (isModernServer ? config.name : config.title)
.replace(/[^A-Za-z0-9\s-]/g, '')
.replace(/\s+/g, '-')

// From core version 2.35, core apps are hosted at the server root under the /dhis-web-* namespace
if (config.coreApp && isModernServer) {
appUrl += `/dhis-web-${urlSafeAppSlug}/`
} else {
appUrl += `/api/apps/${urlSafeAppSlug}/`
}

// Prior to core version 2.35, installed applications did not properly serve "pretty" urls (`/` vs `/index.html`)
if (!isModernServer) {
appUrl += 'index.html'
}

// Clean up any double slashes
const scheme = appUrl.substr(0, appUrl.indexOf(':https://') + 3)
appUrl = scheme + appUrl.substr(scheme.length).replace(/\/+/g, '/')
return appUrl
}
9 changes: 9 additions & 0 deletions cli/src/lib/httpClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const axios = require('axios').default

module.exports.createClient = ({ baseUrl, auth, ...options }) => {
return axios.create({
baseURL: baseUrl,
auth: auth,
...options,
})
}
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [`d2-app-scripts build`](scripts/build.md)
- [`d2-app-scripts start`](scripts/start.md)
- [`d2-app-scripts test`](scripts/test.md)
- [`d2-app-scripts deploy`](scripts/test.md)
- [**Configuration**](config.md)
- [Types - `app`, `lib`](config/types)
- [`d2.config.js` Reference](config/d2-config-js-reference.md)
Expand Down
29 changes: 29 additions & 0 deletions docs/scripts/deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# d2 app scripts deploy

Deploys a built application bundle to a running DHIS2 instance.

Note that you must run `d2 app scripts build` **before** running `deploy`

This command will prompt the user for the URL of the DHIS2 instance as well as a username and password to authenticate with that instance. The URL can be passed optionally as the first positional argument to the command, and the username with the `--username` or `-u` option. The password can be specified with the `D2_PASSWORD` environment variable. For example, the following will deploy the app without waiting for user input.

```sh
> d2 app scripts build
> export D2_PASSWORD=district
> d2 app scripts deploy https://play.dhis2.org/dev --username admin
```

## Usage

```sh
> d2 app scripts deploy --help
d2-app-scripts deploy [baseUrl]

Deploy the built application to a specific DHIS2 instance

Options:
--cwd working directory to use (defaults to cwd)
--version Show version number [boolean]
--config Path to JSON config file
--username, -u The username for authenticating with the DHIS2 instance
-h, --help Show help [boolean]
```
3 changes: 2 additions & 1 deletion examples/simple-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"scripts": {
"start": "../../cli/bin/d2-app-scripts start",
"build": "../../cli/bin/d2-app-scripts build",
"test": "../../cli/bin/d2-app-scripts test"
"test": "../../cli/bin/d2-app-scripts test",
"deploy": "../../cli/bin/d2-app-scripts deploy"
},
"resolutions": {
"@dhis2/app-shell": "file:../../shell",
Expand Down
Loading

0 comments on commit 655a053

Please sign in to comment.