Skip to content

Commit

Permalink
feat(publish): upload apps to App Hub (#532)
Browse files Browse the repository at this point in the history
* feat(publish): implement upload of version to App Hub

* fix: remove appId as required param

* fix: update CI missing params error

* refactor: cleanup

* fix: cleanup

* fix(refactor): rename apiKey to apikey

* docs(publish): add docs for publish command

* docs: update apikey option name

* fix: validate config-file

* fix: use more specific env-var

* fix: remove check for minVersion - refactor reoslveBundle

* refactor: fixes for review
  • Loading branch information
Birkbjo committed May 28, 2021
1 parent 2542503 commit b8c86b6
Show file tree
Hide file tree
Showing 2 changed files with 318 additions and 0 deletions.
274 changes: 274 additions & 0 deletions cli/src/commands/publish.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
const path = require('path')
const { reporter, chalk } = require('@dhis2/cli-helpers-engine')
const FormData = require('form-data')
const fs = require('fs-extra')
const inquirer = require('inquirer')
const { createClient } = require('../lib/httpClient')
const parseConfig = require('../lib/parseConfig')
const makePaths = require('../lib/paths')

const constructUploadUrl = appId => `/api/v1/apps/${appId}/versions`

const isValidServerVersion = v => !!/(\d+)\.(\d+)/.exec(v)

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 requiredFields = new Set(['id', 'version', 'minDHIS2Version'])
const configFieldValidations = {
minDHIS2Version: v =>
isValidServerVersion(v) ? true : 'Invalid server version',
maxDHIS2Version: v =>
isValidServerVersion(v) ? true : 'Invalid server version',
}

const validateFields = (
config,
fieldNames = ['id', 'version', 'minDHIS2Version']
) => {
fieldNames.forEach(fieldName => {
const fieldValue = config[fieldName]
if (
requiredFields.has(fieldName) &&
(fieldValue == null || fieldValue === '')
) {
reporter.error(
`${fieldName} not found in config. Add an ${chalk.bold(
fieldName
)}-field to ${chalk.bold('d2.config.js')}`
)
process.exit(1)
}
const fieldValidation = configFieldValidations[fieldName]
if (fieldValidation) {
const valid = fieldValidation(fieldValue)
if (typeof valid === 'string') {
reporter.error(`${fieldName}: ${valid}`)
process.exit(1)
} else if (!valid) {
reporter.error(`Invalid ${fieldName}`)
process.exit(1)
}
}
})
}

const resolveBundleFromParams = (cwd, params) => {
const appBundle = {}
try {
const filePath = path.resolve(cwd, params.file)
if (!fs.statSync(filePath).isFile()) {
reporter.error(`${params.file} is not a file`)
process.exit(1)
}
appBundle.id = params.appId
appBundle.version = params.fileVersion
appBundle.path = filePath
appBundle.name = path.basename(filePath)
appBundle.minDHIS2Version = params.minDHIS2Version
appBundle.maxDHIS2Version = params.maxDHIS2Version
return appBundle
} catch (e) {
reporter.error(`File does not exist at ${params.file}`)
process.exit(1)
}
}

const resolveBundleFromAppConfig = cwd => {
// resolve file from built-bundle
const appBundle = {}
const paths = makePaths(cwd)
const builtAppConfig = parseConfig(paths)
validateFields(builtAppConfig)

appBundle.id = builtAppConfig.id
appBundle.version = builtAppConfig.version
appBundle.path = path.relative(
cwd,
paths.buildAppBundle
.replace(/{{name}}/, builtAppConfig.name)
.replace(/{{version}}/, builtAppConfig.version)
)
appBundle.name = builtAppConfig.name
appBundle.minDHIS2Version = builtAppConfig.minDHIS2Version
appBundle.maxDHIS2Version = builtAppConfig.maxDHIS2Version

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

const resolveBundle = (cwd, params) => {
if (params.file) {
return resolveBundleFromParams(cwd, params)
}
return resolveBundleFromAppConfig(cwd)
}

const promptForConfig = async params => {
const apiKey = params.apikey || process.env.D2_APP_HUB_API_KEY
if (process.env.CI && !apiKey) {
reporter.error('Prompt disabled in CI mode - missing apikey parameter.')
process.exit(1)
}

const responses = await inquirer.prompt([
{
type: 'input',
name: 'apikey',
message: 'App Hub API-key',
when: () => !apiKey,
},
{
type: 'input',
name: 'appId',
message: 'App Hub App-id',
when: () => params.file && !params.appId,
},
{
type: 'input',
name: 'fileVersion',
message: 'Version of the app',
when: () => params.file && !params.fileVersion,
},
{
type: 'input',
name: 'minDHIS2Version',
message: 'Minimum DHIS2 version supported',
when: () => params.file && !params.minDHIS2Version,
validate: v =>
isValidServerVersion(v) ? true : 'Invalid server version',
},
{
type: 'input',
name: 'maxDHIS2Version',
message: 'Maximum DHIS2 version supported',
when: () => params.file && !params.maxDHIS2Version,
validate: v =>
!v || isValidServerVersion(v) ? true : 'Invalid server version',
},
])

return {
...params,
apikey: apiKey,
...responses,
}
}

const handler = async ({ cwd = process.cwd(), ...params }) => {
const publishConfig = await promptForConfig(params)

const appBundle = resolveBundle(cwd, publishConfig)
const uploadAppUrl = constructUploadUrl(appBundle.id)

const client = createClient({
baseUrl: publishConfig.baseUrl,
headers: {
'x-api-key': publishConfig.apikey,
},
})

const versionData = {
version: appBundle.version,
minDhisVersion: appBundle.minDHIS2Version,
maxDhisVersion: appBundle.maxDHIS2Version || '',
channel: publishConfig.channel,
}

const formData = new FormData()
formData.append('file', fs.createReadStream(appBundle.path))
formData.append('version', JSON.stringify(versionData))

try {
reporter.print(
`Uploading app bundle to ${publishConfig.baseUrl + uploadAppUrl}`
)
reporter.debug('Upload with version data', versionData)

await client.post(uploadAppUrl, formData, {
headers: formData.getHeaders(),
timeout: 300000, // Ensure we have enough time to upload a large zip file
})
reporter.info(
`Successfully published ${appBundle.name} with version ${appBundle.version}`
)
} catch (e) {
if (e.isAxiosError) {
dumpHttpError('Failed to upload app, HTTP error', e.response)
} else {
reporter.error(e)
}
process.exit(1)
}
}

const command = {
command: 'publish',
alias: 'p',
desc: 'Deploy the built application to a specific DHIS2 instance',
builder: yargs =>
yargs.options({
apikey: {
alias: 'k',
type: 'string',
description: 'The API-key to use for authentication',
},
channel: {
alias: 'c',
description: 'The channel to publish the app-version to',
default: 'stable',
},
baseUrl: {
alias: 'b',
description: 'The base-url of the App Hub instance',
default: 'https://apps.dhis2.org',
},
minDHIS2Version: {
type: 'string',
description: 'The minimum version of DHIS2 the app supports',
},
maxDHIS2Version: {
type: 'string',
description: 'The maximum version of DHIS2 the app supports',
},
appId: {
type: 'string',
description:
'Only used with --file option. The App Hub ID for the App to publish to',
implies: 'file',
},
file: {
description:
'Path to the file to upload. This skips automatic resolution of the built app and uses this file-path to upload',
},
'file-version': {
type: 'string',
description:
'Only used with --file option. The semantic version of the app uploaded',
implies: 'file',
},
}),
handler,
}

module.exports = command
44 changes: 44 additions & 0 deletions docs/scripts/publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# d2 app scripts publish

Publishes a built application bundle to the [App Hub](https://apps.dhis2.org/).

You must run `d2 app scripts build` **before** running `publish`

Note that you need an App Hub API key before using this command. The API key is used to identify the user uploading the app. You can generate an API key after logging into the [App Hub](https://apps.dhis2.org/).

This command can only upload _versions_ to an app that **already** exists on the App Hub. The app must have an `id`-field in the `d2.config.js` which matches the id of the app on App Hub.

This command will prompt the user for information not found in `d2.config.js` or in the parameters. The API key can be specified with the `D2_APP_HUB_API_KEY` environment variable. For example, the following will publish the app without waiting for user input, given that `id` is in the config-file.

```sh
> d2 app scripts publish
> export D2_APIKEY=xyz
> d2 app scripts publish --minVersion 2.34 --maxVersion 2.36
```

## Usage

```sh
> d2 app scripts publish --help
d2-app-scripts publish

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
--apikey, -k The API-key to use for authentication [string]
--channel, -c The channel to publish the app-version to [default: "stable"]
--baseUrl, -b The base-url of the App Hub instance
[default: "https://apps.dhis2.org"]
--minVersion The minimum version of DHIS2 the app supports [string]
--maxVersion The maximum version of DHIS2 the app supports [string]
--appId Only used with --file option. The App Hub ID for the App to
publish to [string]
--file Path to the file to upload. This skips automatic resolution of
the built app and uses this file-path to upload
--file-version Only used with --file option. The semantic version of the app
uploaded [string]
-h, --help Show help [boolean]
```

0 comments on commit b8c86b6

Please sign in to comment.