Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ Before upgrading a CI pipeline to 1.2.0, note two changes: an unknown flag is no
- [Rolling Back Updates](#rolling-back-updates)
- [Auto-Rollback](#auto-rollback)
- [Viewing Release History](#viewing-release-history)
- [Viewing Failed Updates](#viewing-failed-updates)
- [Clearing Release History](#clearing-release-history)
- [Webhooks](#webhooks)

## Usage

Expand Down Expand Up @@ -967,6 +969,26 @@ By default, the history doesn't display the author of each release, but if you a

_NOTE: The history command can also be run using the "h" alias_

## Viewing Failed Updates

When a device installs a release and then rolls it back, most often because the app crashed before calling `notifyAppReady`, the SDK reports a failed update. You can list those reports for a deployment using the following command:

```
dpctl deployment errors <appName> <deploymentName>
[--limit <limit>]
[--format <json|table>]
```

The output starts with a summary: how many devices reported failures, how many reports there were, and which release affected the most devices. Below it, each report shows the release that failed, the last release that worked on that device, the app version, platform and location.

`--limit` sets how many reports to show (50 by default), and `--format json` gives machine-readable output, for example to fail a CI job when a release you just shipped starts failing.

```shell
dpctl deployment errors MyApp-iOS Production --limit 100
```

_NOTE: This reads the same data as the Errors tab in the dashboard, and needs an access key that isn't limited to specific apps._

## Clearing Release History

You can clear the release history associated with a deployment using the following command:
Expand All @@ -977,6 +999,27 @@ dpctl deployment clear <appName> <deploymentName>

After running this command, client devices configured to receive updates using its associated deployment key will no longer receive the updates that have been cleared. This command is irreversible, and therefore should not be used in a production deployment.

## Webhooks

A webhook posts to a URL of yours when something happens in your account, for example a release or a rollback. Webhooks belong to the account, not to a single app.

```
dpctl webhook list [--format <json|table>]
dpctl webhook add <url> [--name <name>] [--events <events>] [--secret <secret>] [--disabled]
dpctl webhook update <id> [--url <url>] [--name <name>] [--events <events>] [--secret <secret>] [--enabled|--disabled]
dpctl webhook remove <id>
```

`--events` takes a comma-separated list, and a webhook with no list receives every event:

```shell
dpctl webhook add https://example.com/hook --name "Deploy channel" --events Upload,Rollback
```

To go back to receiving every event, pass an empty list: `dpctl webhook update <id> --events ""`.

`--secret` is used to sign the payload, so your endpoint can verify a request really came from DeployPulse. `--disabled` adds a webhook without turning it on, or turns an existing one off; `--enabled` turns it back on.

### License

This project contains code originally developed by Microsoft Corporation and
Expand Down
172 changes: 172 additions & 0 deletions script/command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
Org,
CollaboratorProperties,
Deployment,
DeploymentError,
DeploymentErrorsResult,
DeploymentMetrics,
Headers,
Package,
Expand Down Expand Up @@ -741,6 +743,95 @@ function deploymentRename(command: cli.IDeploymentRenameCommand): Promise<void>
});
}

function deploymentErrors(command: cli.IDeploymentErrorsCommand): Promise<void> {
throwForInvalidOutputFormat(command.format);

return sdk
.getDeploymentErrors(command.appName, command.deploymentName)
.then((result: DeploymentErrorsResult): void => printDeploymentErrors(command, result))
.catch((error: any): void => {
// Failure reports come from an account-level endpoint, so an app-scoped key gets a bare 403.
// Say why, rather than letting it read as a permissions bug.
let message = String((error && error.message) || "");
try {
message = JSON.parse(message).message || message;
} catch {
/* not JSON; keep as-is */
}
if (/limited to specific apps/i.test(message)) {
throw new Error(`${message} Failure reports are account-level, so use a key that is not limited to specific apps.`);
}
throw error;
});
}

export function summarizeDeploymentErrors(entries: DeploymentError[]) {
const devices = new Set<string>();
const devicesByLabel = new Map<string, Set<string>>();
let totalReports = 0;
entries.forEach((entry: DeploymentError) => {
devices.add(entry.clientUniqueId);
totalReports += Number(entry.failureCount) || 0;
if (!devicesByLabel.has(entry.label)) devicesByLabel.set(entry.label, new Set<string>());
devicesByLabel.get(entry.label).add(entry.clientUniqueId);
});
let topFailingRelease: string | null = null;
let topDevices = -1;
// Most distinct devices wins, ties broken by label so the output is stable.
Array.from(devicesByLabel.keys())
.sort()
.forEach((label: string) => {
const count = devicesByLabel.get(label).size;
if (count > topDevices) {
topDevices = count;
topFailingRelease = label;
}
});
return { affectedDevices: devices.size, failedReleases: devicesByLabel.size, totalReports, topFailingRelease };
}

function printDeploymentErrors(command: cli.IDeploymentErrorsCommand, result: DeploymentErrorsResult): void {
const limit = Number.isFinite(Number(command.limit)) ? Number(command.limit) : 50;
const summary = summarizeDeploymentErrors(result.entries);

if (command.format === "json") {
printJson({ summary, truncated: result.truncated, entries: result.entries.slice(0, limit) });
return;
}

if (!result.entries.length) {
log(`No failed updates have been reported for the "${command.deploymentName}" deployment of "${command.appName}".`);
return;
}

log(
`${summary.affectedDevices} device(s) reported ${summary.totalReports} failed update(s) across ` +
`${summary.failedReleases} release(s). Release affecting the most devices: ${summary.topFailingRelease}.`
);

printTable(["Last Seen", "Failed Release", "Last Good", "App Version", "Platform", "Device", "Retries", "Location"], (dataSource: any[]) => {
result.entries.slice(0, limit).forEach((entry: DeploymentError) => {
dataSource.push([
formatDate(new Date(entry.lastSeen).getTime()),
entry.label,
entry.lastSuccessfulLabel || "",
entry.appVersion || "",
entry.platform || "",
entry.clientUniqueId ? entry.clientUniqueId.slice(0, 8) : "",
String(entry.failureCount),
[entry.city, entry.region, entry.country].filter(Boolean).join(", "),
]);
});
});

if (result.entries.length > limit) {
log(`Showing ${limit} of ${result.entries.length} reports. Pass --limit to see more, or --format json.`);
}
if (result.truncated) {
log(chalk.yellow("This app has more failure reports than the API returns, so only the most recent are included."));
}
}

function deploymentHistory(command: cli.IDeploymentHistoryCommand): Promise<void> {
throwForInvalidOutputFormat(command.format);

Expand Down Expand Up @@ -873,6 +964,9 @@ export function execute(command: cli.ICommand) {
case cli.CommandType.deploymentHistoryClear:
return deploymentHistoryClear(<cli.IDeploymentHistoryClearCommand>command);

case cli.CommandType.deploymentErrors:
return deploymentErrors(<cli.IDeploymentErrorsCommand>command);

case cli.CommandType.deploymentHistory:
return deploymentHistory(<cli.IDeploymentHistoryCommand>command);

Expand Down Expand Up @@ -930,6 +1024,18 @@ export function execute(command: cli.ICommand) {
case cli.CommandType.sessionList:
return sessionList(<cli.ISessionListCommand>command);

case cli.CommandType.webhookList:
return webhookList(<cli.IWebhookListCommand>command);

case cli.CommandType.webhookAdd:
return webhookAdd(<cli.IWebhookAddCommand>command);

case cli.CommandType.webhookUpdate:
return webhookUpdate(<cli.IWebhookUpdateCommand>command);

case cli.CommandType.webhookRemove:
return webhookRemove(<cli.IWebhookRemoveCommand>command);

case cli.CommandType.sessionRemove:
return sessionRemove(<cli.ISessionRemoveCommand>command);

Expand Down Expand Up @@ -2181,6 +2287,72 @@ function sessionRemove(command: cli.ISessionRemoveCommand): Promise<void> {
}
}

function webhookList(command: cli.IWebhookListCommand): Promise<void> {
throwForInvalidOutputFormat(command.format);
return sdk.getWebhooks().then((webhooks: any[]): void => {
if (command.format === "json") {
printJson(webhooks);
return;
}
if (!webhooks || webhooks.length === 0) {
log('No webhooks found. Use "dpctl webhook add <url>" to create one.');
return;
}
printTable(["ID", "Name", "URL", "Events", "Enabled"], (dataSource: any[]): void => {
webhooks.forEach((webhook: any): void => {
dataSource.push([
webhook.id,
webhook.name ?? "",
webhook.url,
webhook.events ? webhook.events.join(", ") : "(all)",
webhook.enabled ? chalk.green("Yes") : chalk.red("No"),
]);
});
});
});
}

function webhookAdd(command: cli.IWebhookAddCommand): Promise<void> {
const events: string[] | undefined = command.events ? splitEvents(command.events) : undefined;
return sdk
.addWebhook(command.url, command.name, events, command.secret, command.disabled ? false : undefined)
.then((webhook: any): void => {
log(`Successfully added webhook "${webhook.id}" for URL: ${webhook.url}`);
});
}

function webhookUpdate(command: cli.IWebhookUpdateCommand): Promise<void> {
const updates: Record<string, any> = {};
const given = (value: any): boolean => value !== null && value !== undefined;
if (given(command.url)) updates.url = command.url;
if (given(command.name)) updates.name = command.name;
if (given(command.secret)) updates.secret = command.secret;
if (given(command.enabled)) updates.enabled = command.enabled;
// An empty --events is how you go back to receiving every event, so it is a value, not an omission.
if (given(command.events)) updates.events = command.events === "" ? null : splitEvents(command.events);

if (Object.keys(updates).length === 0) {
log("No changes specified. Use --url, --name, --events, --secret, or --enabled/--no-enabled.");
return Q(<void>null);
}
return sdk.updateWebhook(command.id, updates).then((): void => {
log(`Successfully updated webhook "${command.id}".`);
});
}

function webhookRemove(command: cli.IWebhookRemoveCommand): Promise<void> {
return sdk.removeWebhook(command.id).then((): void => {
log(`Successfully removed webhook "${command.id}".`);
});
}

function splitEvents(events: string): string[] {
return events
.split(",")
.map((event: string) => event.trim())
.filter(Boolean);
}

function releaseErrorHandler(error: CodePushError, command: cli.ICommand): void {
if ((<any>command).noDuplicateReleaseError && error.statusCode === AccountManager.ERROR_CONFLICT) {
console.warn(chalk.yellow("[Warning] " + error.message));
Expand Down
Loading
Loading