Skip to content

Commit

Permalink
Merge pull request #36 from guardian/aa/cli
Browse files Browse the repository at this point in the history
feat(cli): Add a CLI to build deep-links to Central ELK
  • Loading branch information
akash1810 authored Dec 19, 2023
2 parents d55a126 + 824038d commit 8a3cc09
Show file tree
Hide file tree
Showing 11 changed files with 378 additions and 0 deletions.
13 changes: 13 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ on:
- main

jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: lint, test, compile
working-directory: cli
run: |
deno fmt --check
deno test
deno task compile
test:
runs-on: ubuntu-latest
steps:
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Release CLI

on:
push:
tags:
- cli-v*

jobs:
release:
runs-on: ubuntu-latest

permissions:
contents: write
packages: write

steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: lint, test, compile
working-directory: cli
run: |
deno fmt --check
deno test
deno task compile
- name: release
working-directory: cli/dist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sha256sum devx-logs > checksum.txt
gh release create ${{ github.ref }} * --generate-notes
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
61 changes: 61 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# DevX Logs CLI

A small tool to deep-link to Central ELK.

## Installation via homebrew

```bash
brew tap guardian/homebrew-devtools
brew install guardian/devtools/devx-logs

# update
brew upgrade devx-logs
```

## Usage

- Open the logs for Riff-Raff in PROD
```bash
devx-logs --space devx --stage PROD --app riff-raff
```
- Display the URL for logs from Riff-Raff in PROD
```bash
devx-logs --space devx --stage PROD --app riff-raff --no-follow
```
- Open the logs for Riff-Raff in PROD, where the level is INFO, and show the
message and logger_name columns
```bash
devx-logs --space devx --stage PROD --app riff-raff --filter level=INFO --filter region=eu-west-1 --column message --column logger_name
```
- Open the logs for the repository 'guardian/prism':
```bash
devx-logs --filter gu:repo.keyword=guardian/prism --column message --column gu:repo
```

See all options via the `--help` flag:

```bash
devx-logs --help
```

## Releasing

Releasing is semi-automated. To release a new version, create a new tag with the
`cli-v` prefix:

```bash
git tag cli-v0.0.1
```

And then push the tag:

```bash
git push --tags
```

This will trigger [a GitHub Action](../.github/workflows/release-cli.yml),
publishing a new version to GitHub releases.

Once a new release is available, update the
[Homebrew formula](https://github.com/guardian/homebrew-devtools/blob/main/Formula/devx-logs.rb)
to point to the new version.
6 changes: 6 additions & 0 deletions cli/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tasks": {
"compile": "deno compile --allow-run=open --target aarch64-apple-darwin --output dist/devx-logs main.ts",
"demo": "deno run --allow-run=open main.ts --space devx --stack deploy --stage PROD --app riff-raff"
}
}
40 changes: 40 additions & 0 deletions cli/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions cli/elk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Wrap a string in single quotes so Kibana can parse it correctly
*/
function wrapString(str: string): string {
return `'${str}'`;
}

export function getLink(
space: string,
filters: Record<string, string>,
columns: string[] = [],
): string {
const kibanaFilters = Object.entries(filters).map(([key, value]) => {
return `(query:(match_phrase:(${wrapString(key)}:${wrapString(value)})))`;
});

// The `#/` at the end is important for Kibana to correctly parse the query string
// The `URL` object moves this to the end of the string, which breaks the link.
const base = `https://logs.gutools.co.uk/s/${space}/app/discover#/`;

const query = {
...(kibanaFilters.length > 0 && {
_g: `(filters:!(${kibanaFilters.join(",")}))`,
}),
...(columns.length > 0 && {
_a: `(columns:!(${columns.map(wrapString).join(",")}))`,
}),
};

const queryString = Object.entries(query)
.map(([key, value]) => `${key}=${value}`)
.join("&");

return `${base}?${queryString}`;
}
35 changes: 35 additions & 0 deletions cli/elk_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
import { getLink } from "./elk.ts";

// NOTE: Each of these URLs should be opened in a browser to verify that they work as expected.

Deno.test("getLink with simple input", () => {
const got = getLink("devx", { app: "riff-raff", stage: "PROD" });
const want =
"https://logs.gutools.co.uk/s/devx/app/discover#/?_g=(filters:!((query:(match_phrase:('app':'riff-raff'))),(query:(match_phrase:('stage':'PROD')))))";
assertEquals(got, want);
});

Deno.test("getLink with columns", () => {
const got = getLink("devx", { app: "riff-raff", stage: "PROD" }, [
"message",
"level",
]);
const want =
"https://logs.gutools.co.uk/s/devx/app/discover#/?_g=(filters:!((query:(match_phrase:('app':'riff-raff'))),(query:(match_phrase:('stage':'PROD')))))&_a=(columns:!('message','level'))";
assertEquals(got, want);
});

/*
Filters and columns with colon(:) input should get wrapped in single quotes(') so that Kibana can parse them correctly.
That is, gu:repo should become 'gu:repo'.
*/
Deno.test("getLink with colon(:) input", () => {
const got = getLink("devx", {
"gu:repo.keyword": "guardian/amigo",
stage: "PROD",
}, ["message", "gu:repo"]);
const want =
"https://logs.gutools.co.uk/s/devx/app/discover#/?_g=(filters:!((query:(match_phrase:('gu:repo.keyword':'guardian/amigo'))),(query:(match_phrase:('stage':'PROD')))))&_a=(columns:!('message','gu:repo'))";
assertEquals(got, want);
});
78 changes: 78 additions & 0 deletions cli/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Args } from "https://deno.land/[email protected]/flags/mod.ts";
import { parse } from "https://deno.land/[email protected]/flags/mod.ts";
import { getLink } from "./elk.ts";
import { parseFilters, removeUndefined } from "./transform.ts";

function parseArguments(args: string[]): Args {
return parse(args, {
boolean: ["follow"],
negatable: ["follow"],
string: ["space", "stack", "stage", "app"],
collect: ["column", "filter"],
stopEarly: false,
"--": true,
default: {
follow: true,
column: ["message"],
space: "default",
filter: [],
},
});
}

function printHelp(): void {
console.log(`Usage: devx-logs [OPTIONS...]`);
console.log("\nOptional flags:");
console.log(" --help Display this help and exit");
console.log(" --space The Kibana space to use");
console.log(" --stack The stack tag to filter by");
console.log(" --stage The stage tag to filter by");
console.log(" --app The app tag to filter by");
console.log(
" --column Which columns to display. Multiple: true. Default: 'message'",
);
console.log(
" --filter Additional filters to apply. Multiple: true. Format: key=value",
);
console.log(" --no-follow Don't open the link in the browser");
console.log("\nExample:");
console.log(
" devx-logs --space devx --stack deploy --stage PROD --app riff-raff",
);
console.log("\nAdvanced example:");
console.log(
" devx-logs --space devx --stack deploy --stage PROD --app riff-raff --filter level=INFO --filter region=eu-west-1 --column message --column logger_name",
);
}

function main(inputArgs: string[]) {
const args = parseArguments(inputArgs);

if (args.help) {
printHelp();
Deno.exit(0);
}

const { space, stack, stage, app, column, filter, follow } = args;

const mergedFilters: Record<string, string | undefined> = {
...parseFilters(filter),
"stack.keyword": stack,
"stage.keyword": stage,
"app.keyword": app,
};

const filters = removeUndefined(mergedFilters);
const link = getLink(space, filters, column);

console.log(link);

if (follow) {
new Deno.Command("open", { args: [link] }).spawn();
}
}

// Learn more at https://deno.land/manual/examples/module_metadata#concepts
if (import.meta.main) {
main(Deno.args);
}
24 changes: 24 additions & 0 deletions cli/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Turn an array of strings of form `key=value` into an object of form `{ key: value }`
*/
export function parseFilters(filter: string[]): Record<string, unknown> {
return filter.reduce((acc, curr) => {
const [key, value] = curr.split("=");
return { ...acc, [key]: value };
}, {});
}

/**
* Remove keys from a `Record` whose value is falsy
*/
export function removeUndefined(
obj: Record<string, string | undefined>,
): Record<string, string> {
return Object.entries(obj).filter(([, value]) => !!value).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value,
}),
{},
);
}
52 changes: 52 additions & 0 deletions cli/transform_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { assertEquals } from "https://deno.land/[email protected]/assert/assert_equals.ts";
import { parseFilters, removeUndefined } from "./transform.ts";

Deno.test("parseFilters", () => {
const got = parseFilters(["stack=deploy", "stage=PROD", "app=riff-raff"]);
const want = {
stack: "deploy",
stage: "PROD",
app: "riff-raff",
};
assertEquals(got, want);
});

Deno.test("parseFilters without an = delimiter", () => {
const got = parseFilters(["message"]);
const want = {
message: undefined,
};
assertEquals(got, want);
});

Deno.test("parseFilters without a value on the RHS of =", () => {
const got = parseFilters(["name="]);
const want = {
name: "",
};
assertEquals(got, want);
});

Deno.test("removeUndefined", () => {
const got = removeUndefined({
stack: "deploy",
stage: undefined,
app: "riff-raff",
team: "",
});
const want = {
stack: "deploy",
app: "riff-raff",
};
assertEquals(got, want);
});

Deno.test("removeUndefined where the RHS is 0", () => {
const got = removeUndefined({
errors: "0",
});
const want = {
errors: "0",
};
assertEquals(got, want);
});

0 comments on commit 8a3cc09

Please sign in to comment.