diff --git a/packages/app/package.json b/packages/app/package.json index d0c6eeafe2..d8a5e30d91 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -33,6 +33,7 @@ "@backstage/theme": "^0.2.16", "@frontside/backstage-plugin-effection-inspector": "^0.1.2", "@frontside/backstage-plugin-humanitec": "^0.3.1", + "@frontside/backstage-plugin-platform": "^0.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "history": "^5.0.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 01754379e1..7d9067b8ec 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -33,6 +33,7 @@ import { FlatRoutes } from '@backstage/core-app-api'; import { orgPlugin } from '@backstage/plugin-org'; import { InspectorPage } from '@frontside/backstage-plugin-effection-inspector'; import { GraphiQLPage } from '@backstage/plugin-graphiql'; +import { PlatformPage } from '@frontside/backstage-plugin-platform'; const app = createApp({ apis, @@ -90,6 +91,7 @@ const routes = ( } /> } /> } /> + }/> ); diff --git a/packages/backend/.eslintrc.js b/packages/backend/.eslintrc.js index e2a53a6ad2..e28be0ab46 100644 --- a/packages/backend/.eslintrc.js +++ b/packages/backend/.eslintrc.js @@ -1 +1,5 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + rules: { + 'prefer-const': 'off' + } +}); diff --git a/packages/backend/.gitignore b/packages/backend/.gitignore new file mode 100644 index 0000000000..ff278ebc46 --- /dev/null +++ b/packages/backend/.gitignore @@ -0,0 +1 @@ +/dist-bin/ diff --git a/packages/backend/package.json b/packages/backend/package.json index 4dd7de56b3..36407876df 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -43,6 +43,7 @@ "@frontside/backstage-plugin-graphql": "^0.4.1", "@frontside/backstage-plugin-incremental-ingestion-backend": "*", "@frontside/backstage-plugin-incremental-ingestion-github": "*", + "@frontside/backstage-plugin-platform-backend": "*", "graphql-modules": "^2.1.0", "@gitbeaker/node": "^34.6.0", "@internal/plugin-healthcheck": "0.1.1", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 79890a0713..61cbe581a4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -34,6 +34,7 @@ import healthcheck from './plugins/healthcheck'; import effectionInspector from './plugins/effection-inspector'; import humanitec from './plugins/humanitec'; import graphql from './plugins/graphql'; +import idp from './plugins/idp'; import { PluginEnvironment } from './types'; import { CatalogClient } from '@backstage/catalog-client'; @@ -99,6 +100,7 @@ async function main() { const searchEnv = useHotMemoize(module, () => createEnv('search')); const appEnv = useHotMemoize(module, () => createEnv('app')); const humanitecEnv = useHotMemoize(module, () => createEnv('humanitec')); + const idpEnv = useHotMemoize(module, () => createEnv('idp')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -111,6 +113,7 @@ async function main() { apiRouter.use('/effection-inspector', await effectionInspector(effectionInspectorEnv)); apiRouter.use('/humanitec', await humanitec(humanitecEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use('/idp', await idp(idpEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 61f287cba4..e09176c066 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -3,9 +3,7 @@ import { } from '@backstage/plugin-catalog-backend'; import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend'; import { IncrementalCatalogBuilder } from '@frontside/backstage-plugin-incremental-ingestion-backend'; -import { GithubRepositoryEntityProvider } from '@frontside/backstage-plugin-incremental-ingestion-github'; import { Router } from 'express'; -import { Duration } from 'luxon'; import { PluginEnvironment } from '../types'; export default async function createPlugin( @@ -16,21 +14,6 @@ export default async function createPlugin( // incremental builder receives builder because it'll register // incremental entity providers with the builder const incrementalBuilder = IncrementalCatalogBuilder.create(env, builder); - - const githubRepositoryProvider = GithubRepositoryEntityProvider.create({ - host: 'github.com', - searchQuery: "created:>1970-01-01 user:thefrontside", - config: env.config - }) - - incrementalBuilder.addIncrementalEntityProvider( - githubRepositoryProvider, - { - burstInterval: Duration.fromObject({ seconds: 3 }), - burstLength: Duration.fromObject({ seconds: 3 }), - restLength: Duration.fromObject({ day: 1 }) - } - ) builder.addProcessor(new ScaffolderEntitiesProcessor()); diff --git a/packages/backend/src/plugins/idp.ts b/packages/backend/src/plugins/idp.ts new file mode 100644 index 0000000000..8b5ae33f94 --- /dev/null +++ b/packages/backend/src/plugins/idp.ts @@ -0,0 +1,78 @@ +import type { Router } from 'express'; +import { createRouter } from '@frontside/backstage-plugin-platform-backend'; +import { createHumanitecPlatformApi } from '@frontside/backstage-plugin-humanitec-common'; +import { PluginEnvironment } from '../types'; +import { Entity } from '@backstage/catalog-model'; + +export default async function createPlugin({ + config, + logger, + discovery, + catalog, +}: PluginEnvironment): Promise { + return await createRouter({ + executableName: 'idp', + logger, + discovery, + appURL: `${config.getString('app.baseUrl')}/platform`, + catalog, + platform: { + async getRepositoryUrls(ref) { + const entity = await ref.load(); + + if (entity) { + const slug = getGithubProjectSlug(entity); + return { + ssh: `git@github.com:${slug}.git`, + https: `https://github/${slug}.git` + } + } + + return null; + }, + async getRepositories() { + const { items: entities } = await catalog.getEntities(); + + const repositories = entities.flatMap(entity => { + const slug = getGithubProjectSlug(entity); + if (slug) { + return [{ + componentRef: getComponentRef(entity), + slug, + description: entity.metadata.description, + url: `https://github/${slug}`, + }] + } + return []; + }); + return { + hasNextPage: false, + hasPreviousPage: false, + beginCursor: '', + endCursor: '', + items: repositories.map(r => ({ + cursor: '', + value: r + })) + } + }, + ...createHumanitecPlatformApi({ + token: config.getString('humanitec.token'), + }), + }, + }); +} + +function getGithubProjectSlug(entity: Entity) { + return entity.metadata + && entity.metadata.annotations + && entity.metadata.annotations["github.com/project-slug"]; +} + +function getComponentRef(entity: Entity) { + return [ + entity.kind !== 'Component' ? entity.kind.toLowerCase() : '', + entity.metadata.namespace && entity.metadata.namespace !== 'default' ? entity.metadata.namespace : '', + entity.metadata.name + ].join('') +} diff --git a/plugins/humanitec-common/src/clients/humanitec.ts b/plugins/humanitec-common/src/clients/humanitec.ts index 7b07a37a49..7b5e80a093 100644 --- a/plugins/humanitec-common/src/clients/humanitec.ts +++ b/plugins/humanitec-common/src/clients/humanitec.ts @@ -60,6 +60,18 @@ export function createHumanitecClient({ orgId, token }: { token: string; orgId: const result = await _fetch('GET', `apps/${appId}/envs/${envId}/resources`); return ResourcesResponsePayload.parse(result); }, + + async getEnvironmentLogsSnapshot(appId: string, envId: string) { + type Message = { + workload_id: string; + container_id: string; + payload: string; + level: string + }; + + return await _fetch('GET', `apps/${appId}/envs/${envId.toLowerCase()}/logs?limit=100&invert=true`); + + }, buildUrl(params: URLs) { const baseUrl = `https://app.humanitec.io/orgs/${orgId}`; switch (params.resource) { @@ -89,6 +101,8 @@ export function createHumanitecClient({ orgId, token }: { token: string; orgId: if (r.ok) { return await r.json() as R; + } else { + console.dir({ error: r.statusText }); } throw new FetchError(`Fetch ${method} to ${url} failed.`, r); @@ -98,4 +112,4 @@ export function createHumanitecClient({ orgId, token }: { token: string; orgId: retry: async (e: FetchError) => e.status === 403 }); } -} \ No newline at end of file +} diff --git a/plugins/humanitec-common/src/constants.ts b/plugins/humanitec-common/src/constants.ts new file mode 100644 index 0000000000..e94ede15d3 --- /dev/null +++ b/plugins/humanitec-common/src/constants.ts @@ -0,0 +1,2 @@ +export const HUMANITEC_ORG_ID_ANNOTATION = "humanitec.com/orgId"; +export const HUMANITEC_APP_ID_ANNOTATION = "humanitec.com/appId"; diff --git a/plugins/humanitec-common/src/index.ts b/plugins/humanitec-common/src/index.ts index 51075d7dec..6bae1a7cb4 100644 --- a/plugins/humanitec-common/src/index.ts +++ b/plugins/humanitec-common/src/index.ts @@ -4,4 +4,6 @@ export * from './types/environment'; export * from './types/resources'; export * from './types/runtime'; export * from './clients/fetch-app-info'; -export * from './clients/humanitec'; \ No newline at end of file +export * from './clients/humanitec'; +export * from './platform-api'; +export * from './constants'; diff --git a/plugins/humanitec-common/src/platform-api.ts b/plugins/humanitec-common/src/platform-api.ts new file mode 100644 index 0000000000..86d91a9ad0 --- /dev/null +++ b/plugins/humanitec-common/src/platform-api.ts @@ -0,0 +1,68 @@ +import type { PlatformApi } from '@frontside/backstage-plugin-platform-backend'; +import type { Entity } from '@backstage/catalog-model'; + +import { HUMANITEC_APP_ID_ANNOTATION, HUMANITEC_ORG_ID_ANNOTATION } from './constants'; +import { createHumanitecClient } from './clients/humanitec'; + + +export type HumanitecPlatformAPI = Pick +export function createHumanitecPlatformApi({ token }: { token: string }): HumanitecPlatformAPI { + + return { + async *getLogs(ref, envId) { + const entity = await ref.load(); + const { appId, orgId } = getHumanitecMetadata(entity); + const client = createHumanitecClient({ token, orgId }); + const logs = await client.getEnvironmentLogsSnapshot(appId, envId); + const bound = Math.floor(logs.length * .5); + const send = logs.slice(0, bound); + const stream = logs.slice(bound, logs.length); + for (const message of send) { + yield message.payload; + } + for (const message of stream) { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1500)); + yield message.payload; + } + }, + + async getEnvironments(ref) { + const entity = await ref.load(); + const { appId, orgId } = getHumanitecMetadata(entity); + const client = createHumanitecClient({ token, orgId }); + const environments = await client.getEnvironments(appId); + return { + hasNextPage: false, + hasPreviousPage: false, + beginCursor: '', + endCursor: '', + items: environments.map(env => ({ + cursor: '', + value: { + id: env.id, + name: env.name, + type: env.type, + url: `https://app.humanitec.io/orgs/${orgId}/apps/${appId}/envs/${env.id}` + } + })), + }; + } + } +} + +function getHumanitecMetadata(entity: Entity) { + const { + [HUMANITEC_ORG_ID_ANNOTATION]: orgId, + [HUMANITEC_APP_ID_ANNOTATION]: appId, + } = entity.metadata.annotations ?? {}; + + if (!orgId) { + throw new Error(`${entity.kind}:${entity.metadata.name} is not a humanitec entity`); + + } + if (!appId) { + throw new Error(`${entity.kind}:${entity.metadata.name} is not a humanitec entity`); + + } + return { orgId, appId }; +} diff --git a/plugins/platform-backend/.eslintrc.js b/plugins/platform-backend/.eslintrc.js new file mode 100644 index 0000000000..e28be0ab46 --- /dev/null +++ b/plugins/platform-backend/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + rules: { + 'prefer-const': 'off' + } +}); diff --git a/plugins/platform-backend/README.md b/plugins/platform-backend/README.md new file mode 100644 index 0000000000..feba2941e5 --- /dev/null +++ b/plugins/platform-backend/README.md @@ -0,0 +1,14 @@ +# platform-backend + +Welcome to the platform-backend backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn +start` in the root directory, and then navigating to [/platform-backend](http://localhost:3000/platform-backend). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/platform-backend/cli/.vscode/settings.json b/plugins/platform-backend/cli/.vscode/settings.json new file mode 100644 index 0000000000..5e27b51276 --- /dev/null +++ b/plugins/platform-backend/cli/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": false, + "deno.unstable": true +} \ No newline at end of file diff --git a/plugins/platform-backend/cli/cli.ts b/plugins/platform-backend/cli/cli.ts new file mode 100644 index 0000000000..c84311eaa9 --- /dev/null +++ b/plugins/platform-backend/cli/cli.ts @@ -0,0 +1,347 @@ +import { + assert, + blue, + Command, + Entity, + EventSource, + format, + green, + path, + readAll, + red, + yaml, +} from "./deps.ts"; + +export interface CLIOptions { + name: string; + description: string; + apiURL: string; + args: string[]; + target: string; +} + +interface SSEMessage { + type: "log" | "completion" | "error"; + createdAt: string; + body: { + message: string; + error?: { + name: string; + message: string; + }; + }; +} + +class MainError extends Error { + name = "Mainerror"; +} + +const logTextColors: Record string> = { + "log": blue, + "completion": green, + "error": red, +}; + +function logSSEMessage(raw: string) { + const message: SSEMessage = JSON.parse(raw); + const color = logTextColors[message.type]; + const timestamp = format(new Date(message.createdAt), "dd-MM-yyyy:hh:mm"); + const logType = color(`[${message.type.toLocaleUpperCase()} - ${timestamp}]`); + + console.log(`${logType} - ${message.body.message})`); + + if (message.body.error) { + logSSEMessage(JSON.stringify({ + type: "error", + body: { + message: message.body.error.message, + }, + createdAt: message.createdAt, + })); + } +} + +export async function cli(options: CLIOptions) { + let { apiURL, description, args, name, target } = options; + let get: typeof fetch = (endpoint, init) => + fetch(`${apiURL}/${endpoint}`, init); + + let post = (endpoint: string, init: Omit) => + fetch(`${apiURL}/${endpoint}`, { + method: "POST", + ...init, + }); + + const cmd = new Command() + .name(name) + .version(() => `\narchitecture: ${target}\nbackstage: ${apiURL}`) + .description(description) + .command("info", "display info about a backstage component entity.") + .option( + "-c --component ", + "The backstage component entity", + ) + .action(async ({ component }) => { + let ref = await findEntityContext(component); + let response: Response; + try { + response = await get(`components/${ref}/info`); + } catch (error) { + throw new MainError(error.message); + } + if (!response) { + throw new MainError(`no response from server`); + } else if (response.ok) { + await Deno.stdout.write( + new TextEncoder().encode(await response.text()), + ); + } else { + if (response.status === 404) { + throw new MainError(`unknown component '${ref}'`); + } else { + throw new MainError( + `communication error with backstage server: ${response.status} ${response.statusText}`, + ); + } + } + }) + .command( + "clone", + "clone a repository associated with a component", + ) + .option("-S, --ssh", "Use ssh url to clone repository") + .option("-H, --https", "Use https url to clone repository") + .arguments( + "[component:string] [directory:string]", + ) + .action(async ({ ssh, https }, component, directory) => { + const output = directory ?? component; + if (ssh && https) { + throw new MainError( + `Invalid options: --ssh and --https can't be used together - use one or the other.`, + ); + } + // prefer ssh + const protocol = !(ssh && https) || ssh ? "ssh" : "https"; + if (component) { + let response: Response; + try { + response = await get(`repositories/${component}/urls`); + } catch (error) { + throw new MainError(error.message); + } + if (response.ok) { + const urls = await response.json(); + const url = urls[protocol]; + const clone = Deno.run({ + cmd: ["git", "clone", url, output], + }); + if (!(await clone.status()).success) { + throw new MainError( + `Encountered an error cloning "${url}" to "${output}".`, + ); + } + } else if (response.status === 404) { + throw new MainError(`unknown component '${component}'`); + } else { + throw new MainError( + `communication error with backstage server: ${response.status} ${response.statusText}`, + ); + } + return; + } + + let response: Response; + try { + response = await get(`repositories`, { + headers: { + Accept: "text/plain", + }, + }); + } catch (error) { + throw new MainError(error.message); + } + if (response.ok) { + await Deno.stdout.write( + new TextEncoder().encode(await response.text()), + ); + } else { + throw new MainError( + `communication error with backstage server: ${response.status} ${response.statusText}`, + ); + } + }) + .command( + "environments", + "list enviroments in which a component is deployed", + ) + .option( + "-c --component ", + "The backstage component entity", + ) + .action(async ({ component }) => { + let ref = await findEntityContext(component); + let response: Response; + try { + response = await get(`components/${ref}/environments`); + } catch (error) { + throw new MainError(error.message); + } + if (!response) { + throw new MainError(`no response from server`); + } else if (response.ok) { + await Deno.stdout.write( + new TextEncoder().encode(await response.text()), + ); + } else { + if (response.status === 404) { + throw new MainError(`unknown component '${ref}'`); + } else { + throw new MainError( + `communication error with backstage server: ${response.status} ${response.statusText}`, + ); + } + } + }) + .command( + "create", + `create something new from a template. +usage: + +# heredoc +<", "the scaffolder template", { + default: "standard-microservice", + }) + .option( + "-f --file ", + `an optional file path to a file containing the template's fields`, + ) + .arguments("[input]") + .action(async ({ template, file }, input) => { + let body: string | undefined; + + if (input === "-") { + const stdinContent = await readAll(Deno.stdin); + body = new TextDecoder().decode(stdinContent); + } else if (file) { + body = Deno.readTextFileSync(file); + } + + assert(body, `no body has been created.`); + + const response = await post(`create/${template}`, { + headers: { + "Content-Type": "text/plain", + }, + body, + }); + + if (response.status !== 200) { + throw new MainError( + `create failed with ${response.status} - ${response.statusText}`, + ); + } + + // deno-lint-ignore no-explicit-any + function sseMessageHandler(event: any) { + if (event.data) { + try { + logSSEMessage(event.data); + } catch (ex) { + console.error(ex); + } + } + } + + const { taskId } = await response.json(); + + const eventSourceUrl = `${apiURL}/tasks/${taskId}/eventstream`; + + const eventSource = new EventSource(eventSourceUrl, { + withCredentials: true, + }); + + eventSource.addEventListener("log", sseMessageHandler); + eventSource.addEventListener("completion", (event: any) => { + sseMessageHandler(event); + + eventSource.close(); + }); + eventSource.addEventListener("error", sseMessageHandler); + }) + .command("logs") + .option( + "-c --component ", + "the component for which to fetch the logs", + ) + .action(async ({ component }) => { + let name = await findEntityContext(component); + let response = await get(`logs/${name}`); + await response.body?.pipeTo(Deno.stdout.writable); + }); + + try { + await cmd.parse(args); + } catch (error) { + if (error instanceof MainError) { + console.log(error.message); + } else { + throw error; + } + } +} + +async function findEntityContext(component?: string): Promise { + if (component) { + return component; + } + let catalogInfoYaml = await findAndRead( + "catalog-info.yml", + "catalog-info.yaml", + ); + if (catalogInfoYaml.found) { + let [info] = yaml.parseAll(catalogInfoYaml.content) as Iterable; + if (info && info.metadata?.name) { + return info.metadata.name; + } + } else { + throw new MainError( + "unable to determine the component. You can set it explicitly by passing the `--component` flag", + ); + } + + return ""; +} + +type Find = { + found: false; +} | { + found: true; + content: string; +}; +async function findAndRead(...paths: string[]): Promise { + for (let cwd = Deno.cwd(); cwd !== "/"; cwd = path.join(cwd, "..")) { + for (let path of paths) { + try { + let content = new TextDecoder().decode( + await Deno.readFile(`${cwd}/${path}`), + ); + return { found: true, content }; + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) { + throw error; + } + } + } + } + return { found: false }; +} diff --git a/plugins/platform-backend/cli/deno.json b/plugins/platform-backend/cli/deno.json new file mode 100644 index 0000000000..a1cea42e11 --- /dev/null +++ b/plugins/platform-backend/cli/deno.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "dev": "deno run --location=http://localhost:7007 --allow-env --allow-read --allow-net=localhost:7007 --allow-run tasks/dev.ts" + }, + "lint": { + "rules": { + "exclude": ["prefer-const"] + } + } +} diff --git a/plugins/platform-backend/cli/deps.ts b/plugins/platform-backend/cli/deps.ts new file mode 100644 index 0000000000..78f79a57dc --- /dev/null +++ b/plugins/platform-backend/cli/deps.ts @@ -0,0 +1,116 @@ +export { assert } from "https://deno.land/std@0.159.0/testing/asserts.ts"; +export * as path from "https://deno.land/std@0.159.0/path/mod.ts"; +export * as yaml from "https://deno.land/std@0.159.0/encoding/yaml.ts"; +export { format } from "https://deno.land/std@0.159.0/datetime/mod.ts"; +export { Command } from "https://deno.land/x/cliffy@v0.25.2/command/mod.ts"; +export { EventSource } from "https://deno.land/x/eventsource@v0.0.2/mod.ts"; +export { blue, green, red } from "https://deno.land/std@0.159.0/fmt/colors.ts"; +export { readAll } from "https://deno.land/std@0.159.0/streams/conversion.ts?s=copy"; + +// we tried to get this from backstage. We really did. +export interface Entity { + /** + * The version of specification format for this particular entity that + * this is written against. + */ + apiVersion: string; + + /** + * The high level entity type being described. + */ + kind: string; + + /** + * Metadata related to the entity. + */ + metadata: EntityMeta; + + /** + * The specification data describing the entity itself. + */ + spec?: Record; +} + +export interface EntityMeta { + /** + * A globally unique ID for the entity. + * + * This field can not be set by the user at creation time, and the server + * will reject an attempt to do so. The field will be populated in read + * operations. The field can (optionally) be specified when performing + * update or delete operations, but the server is free to reject requests + * that do so in such a way that it breaks semantics. + */ + uid?: string; + + /** + * An opaque string that changes for each update operation to any part of + * the entity, including metadata. + * + * This field can not be set by the user at creation time, and the server + * will reject an attempt to do so. The field will be populated in read + * operations. The field can (optionally) be specified when performing + * update or delete operations, and the server will then reject the + * operation if it does not match the current stored value. + */ + etag?: string; + + /** + * The name of the entity. + * + * Must be unique within the catalog at any given point in time, for any + * given namespace + kind pair. This value is part of the technical + * identifier of the entity, and as such it will appear in URLs, database + * tables, entity references, and similar. It is subject to restrictions + * regarding what characters are allowed. + * + * If you want to use a different, more human readable string with fewer + * restrictions on it in user interfaces, see the `title` field below. + */ + name: string; + + /** + * The namespace that the entity belongs to. + */ + namespace?: string; + + /** + * A display name of the entity, to be presented in user interfaces instead + * of the `name` property above, when available. + * + * This field is sometimes useful when the `name` is cumbersome or ends up + * being perceived as overly technical. The title generally does not have + * as stringent format requirements on it, so it may contain special + * characters and be more explanatory. Do keep it very short though, and + * avoid situations where a title can be confused with the name of another + * entity, or where two entities share a title. + * + * Note that this is only for display purposes, and may be ignored by some + * parts of the code. Entity references still always make use of the `name` + * property, not the title. + */ + title?: string; + + /** + * A short (typically relatively few words, on one line) description of the + * entity. + */ + description?: string; + + /** + * Key/value pairs of identifying information attached to the entity. + */ + labels?: Record; + + /** + * Key/value pairs of non-identifying auxiliary information attached to the + * entity. + */ + annotations?: Record; + + /** + * A list of single-valued strings, to for example classify catalog entities in + * various ways. + */ + tags?: string[]; +} diff --git a/plugins/platform-backend/cli/install.sh b/plugins/platform-backend/cli/install.sh new file mode 100644 index 0000000000..64ee2d2d02 --- /dev/null +++ b/plugins/platform-backend/cli/install.sh @@ -0,0 +1,219 @@ +executable_name() { + echo "{{executableName}}" +} + +executables_url() { + echo "{{appURL}}" +} + +downloads_url() { + echo "{{downloadsURL}}" +} + +info() { + local action="$1" + local details="$2" + command printf '\033[1;32m%12s\033[0m %s\n' "$action" "$details" 1>&2 +} + +error() { + command printf '\033[1;31mError\033[0m: %s\n\n' "$1" 1>&2 +} + +warning() { + command printf '\033[1;33mWarning\033[0m: %s\n\n' "$1" 1>&2 +} + +request() { + command printf '\033[1m%s\033[0m\n' "$1" 1>&2 +} + +eprintf() { + command printf '%s\n' "$1" 1>&2 +} + +bold() { + command printf '\033[1m%s\033[0m' "$1" +} + +usage() { + cat >&2 </dev/null + +# default to running setup after installing +should_run_setup="true" + +# install to IDP_HOME, defaulting to ~/.volta +install_dir="${IDP_HOME:-"$HOME/.idp"}" + +# parse command line options +while [ $# -gt 0 ] +do + arg="$1" + + case "$arg" in + -h|--help) + usage + exit 0 + ;; + --skip-setup) + shift # shift off the argument + should_run_setup="false" + ;; + *) + error "unknown option: '$arg'" + usage + exit 1 + ;; + esac +done + +install "$install_dir" "$should_run_setup" diff --git a/plugins/platform-backend/cli/main.ts b/plugins/platform-backend/cli/main.ts new file mode 100644 index 0000000000..d225bb52ac --- /dev/null +++ b/plugins/platform-backend/cli/main.ts @@ -0,0 +1,16 @@ +import { assert } from "./deps.ts"; +import { cli } from "./cli.ts"; + +let [name, apiURL, description, ...args] = Deno.args; + +assert(name, "compiled incorrectly - executable name is not defined"); +assert(apiURL, "compiled incorrectly - backstage platform url is not found"); +assert(description, "compiled incorrectly - no platform description defined"); + +await cli({ + name, + description, + apiURL, + args, + target: Deno.build.target, +}).catch((error) => console.error(error)); diff --git a/plugins/platform-backend/cli/tasks/dev.ts b/plugins/platform-backend/cli/tasks/dev.ts new file mode 100644 index 0000000000..313e81051c --- /dev/null +++ b/plugins/platform-backend/cli/tasks/dev.ts @@ -0,0 +1,9 @@ +import { cli } from "../cli.ts"; + +await cli({ + name: "idp", + description: "internal developer platform ", + apiURL: "http://localhost:7007/api/idp", + args: Deno.args, + target: Deno.build.target, +}).catch((error) => console.error(error)); diff --git a/plugins/platform-backend/package.json b/plugins/platform-backend/package.json new file mode 100644 index 0000000000..7a84d216c9 --- /dev/null +++ b/plugins/platform-backend/package.json @@ -0,0 +1,56 @@ +{ + "name": "@frontside/backstage-plugin-platform-backend", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "^0.15.2", + "@backstage/config": "^1.0.1", + "@types/express": "*", + "cli-table3": "^0.6.3", + "chalk": "^4.1.2", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "js-yaml": "^4.1.0", + "node-fetch-native": "^0.1.7", + "node-deno": "^0.1.0", + "node-fetch": "^2.6.7", + "nunjucks": "^3.2.3", + "request": "^2.88.2", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.20.0", + "@types/nunjucks": "^3.2.1", + "@types/supertest": "^2.0.8", + "@types/request": "^2.48.8", + "msw": "^0.42.0", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ], + "volta": { + "extends": "../../package.json" + } +} diff --git a/plugins/platform-backend/src/executables.ts b/plugins/platform-backend/src/executables.ts new file mode 100644 index 0000000000..d8d0656bf5 --- /dev/null +++ b/plugins/platform-backend/src/executables.ts @@ -0,0 +1,184 @@ +import type { Logger } from 'winston'; +import type { CompilationTarget } from 'node-deno'; +import { CompilationTargets, run, compile } from 'node-deno'; +import { existsSync } from 'fs'; + +export interface DownloadInfo { + executableName: string; + helpText: Async; + executables: Executables; +} + +export type Executables = Record; + +type Async = { + "type": "pending"; +} | { + "type": "resolved"; + value: T; +} | { + "type": "rejected"; + error: Error; +} + +export type Executable = { + type: 'error'; + error: Error; +} | { + type: 'failure', + stderr: string; + stdout: string; +} | { + type: 'compiled'; + url: string; + stderr: string; + stdout: string; +} | { + type: 'compiling'; + stderr: string; + stdout: string; +} + +export interface FindOrCreateOptions { + baseURL: string; + downloadsURL: string; + logger: Logger; + distDir: string; + executableName: string; + entrypoint: string; +} + +export function getDownloadInfo(options: FindOrCreateOptions): DownloadInfo { + let { executableName } = options; + + let executables = findOrCreateExecutables(options); + + let helpText = getHelpText(options); + + return { executableName, helpText, executables }; +} + +export function getHelpText(options: FindOrCreateOptions): Async { + let { executableName, entrypoint, baseURL, logger } = options; + let description = "internal developer platform"; + + let value: Async = { "type": "pending" }; + + run({ + entrypoint: [entrypoint, executableName, baseURL, `"${description}"`, "--help"], + }).then(result => { + if (result.code != 0) { + logger.info(`help text generation failed: ${result.stderr}`); + value = { "type": "rejected", error: new Error(result.stderr) }; + } else { + logger.info("help text generated for platform executable"); + value = { "type": "resolved", value: result.stdout }; + } + }).catch(error => { + logger.error(`help text generation failed: ${error}`); + value = { "type": "rejected", error }; + }); + + return new Proxy({} as Async, { + get(_, prop: keyof Executable) { + return value[prop]; + }, + ownKeys: () => Object.keys(value), + getOwnPropertyDescriptor: (_, key) => ({ + value: value[key as keyof Executable], + enumerable: true, + configurable: true, + }) + }) +} + +export function findOrCreateExecutables(options: FindOrCreateOptions): Executables { + options.logger.info(`generating executables for ${options.executableName}`); + return CompilationTargets.reduce((executables, target) => { + return { + ...executables, + [target]: findOrCreateExecutable(target, options), + } + }, {}) as Executables; +} + +function findOrCreateExecutable(target: CompilationTarget, options: FindOrCreateOptions): Executable { + let { logger, baseURL, distDir, executableName, entrypoint, downloadsURL } = options; + let output = `${distDir}/${executableName}-${target}`; + let url = `${downloadsURL}/${executableName}-${target}`; + + let executable: Executable = { + type: 'compiling', + stdout: '', + stderr: '', + } + + let ext = target.includes('windows') ? '.exe' : '' + + if (existsSync(`${output}${ext}`)) { + logger.info(`found existing executable: ${output}`); + executable = { + type: 'compiled', + url, + stdout: '', + stderr: '', + } + } else { + logger.info(`compiling ${executableName} for ${target}`); + compile({ + target, + output, + // pass metadata as the first three args of the script + entrypoint: [entrypoint, executableName, baseURL, '"internal developer platform"'], + + // only allow network access back to the backstage server + allowNet: [new URL(baseURL).host], + + allowRead: true, + + allowRun: true, + + location: baseURL, + + // fail immediately if a permission is not present + noPrompt: true, + }).then(result => { + let stdio = { + stdout: result.stdout, + stderr: result.stderr, + }; + if (result.code !== 0) { + logger.error(`compilation for ${target} failed: ${stdio.stderr}`); + executable = { + type: 'failure', + ...stdio, + } + } else { + logger.info(`compilation complete: ${output}`); + executable = { + type: 'compiled', + url, + ...stdio, + } + } + }).catch(error => { + logger.error(`compilation error: ${error}`); + executable = { + type: 'error', + error + } + }); + } + + return new Proxy({} as Executable, { + get(_, prop: keyof Executable) { + return executable[prop]; + }, + ownKeys: () => Object.keys(executable), + getOwnPropertyDescriptor: (_, key) => ({ + value: executable[key as keyof Executable], + enumerable: true, + configurable: true, + }) + }); +} diff --git a/plugins/platform-backend/src/index.ts b/plugins/platform-backend/src/index.ts new file mode 100644 index 0000000000..35b4d00bc4 --- /dev/null +++ b/plugins/platform-backend/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './service/router'; +export * from './types'; +export type { DownloadInfo, Executables } from './executables'; diff --git a/plugins/platform-backend/src/service/router.ts b/plugins/platform-backend/src/service/router.ts new file mode 100644 index 0000000000..441e180249 --- /dev/null +++ b/plugins/platform-backend/src/service/router.ts @@ -0,0 +1,191 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler, PluginEndpointDiscovery, resolvePackagePath } from '@backstage/backend-common'; +import type { CatalogClient } from '@backstage/catalog-client'; +import express from 'express'; +import Router from 'express-promise-router'; +import { readFile } from 'fs/promises'; +import { load } from 'js-yaml'; +import fetch from 'node-fetch-native'; +import * as nunjucks from 'nunjucks'; +import request from 'request'; +import type { Logger } from 'winston'; +import { getDownloadInfo } from '../executables'; +import { GetComponentRef, PlatformApi } from '../types'; +import { Repositories } from './routes/repositories'; +import { Logs } from './routes/logs'; +import CliTable3 from 'cli-table3'; +import chalk from 'chalk'; + +export interface RouterOptions { + logger: Logger; + discovery: PluginEndpointDiscovery; + executableName: string; + appURL: string; + catalog: CatalogClient; + platform: PlatformApi; +} + +export async function createRouter( + options: RouterOptions, + ): Promise { + const { catalog, logger, discovery, executableName, appURL, platform } = options; + + let baseURL = await discovery.getBaseUrl('idp'); + let downloadsURL = `${baseURL}/executables/dist`; + let scaffolderUrl = `${await discovery.getBaseUrl('scaffolder')}/v2/tasks`; + + let executables = getDownloadInfo({ + logger, + distDir: 'dist-bin', + baseURL, + downloadsURL, + executableName, + entrypoint: resolvePackagePath("@frontside/backstage-plugin-platform-backend", "cli", "main.ts"), + }); + + const getComponentRef: GetComponentRef = async (name) => { + return { + ref: `component:default/${name}`, + compound: { + kind: 'component', + name, + namespace: 'default' + }, + load: async () => { + const entity = await catalog.getEntityByRef(`component:default/${name}`); + if (!entity) { + throw new Error(`Component ${name} not found.`); + } + return entity; + }, + }; + } + + const router = Router(); + router.use(express.text()); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.send({ status: 'ok' }); + }); + + router.get('/install.sh', async (_, response) => { + response.setHeader('Content-Type', 'text/plain'); + let installerBytes = await readFile(resolvePackagePath("@frontside/backstage-plugin-platform-backend", "cli", "install.sh")); + response.send(nunjucks.renderString(String(installerBytes), { + appURL, + downloadsURL, + executableName, + })); + }); + + router.get('/executables', (_, response)=> { + response.send(executables); + }); + + router.use('/executables/dist', express.static('dist-bin')); + + router.get('/components/:name/info', async (req, res) => { + let name = req.params.name; + let component = await catalog.getEntityByRef(`component:default/${name}`); + if (component) { + res.send(`${JSON.stringify(component, null, 2)}\n`); + } else { + res.sendStatus(404); + res.send("Not Found"); + } + }) + + router.get('/components/:name/environments', async (req, res) => { + let name = req.params.name; + let ref = await getComponentRef(name); + if (ref) { + const table = new CliTable3({ + head: ["ID", "Name", "Type", "URL"] + }); + let environments = await platform.getEnvironments(ref); + table.push( + ...environments.items.map(({ value: r }) => ([r.id, r.name, r.type, r.url ])) + ) + res.send(`\n${chalk.bold(' ☀️ Deployment Environments')}\n${table}\n`); + } else { + res.sendStatus(404); + res.send("Not Found"); + } + }); + + router.post('/create/:template', async (req, res) => { + const template = req.params.template; + + logger.info(`creating template ${template}`); + + try { + const values = load(req.body) as Record; + + const post = await fetch(scaffolderUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + templateRef: `template:default/${template}`, + values: { + ...values + }, + secrets: {} + }) + }); + + if(post.status !== 201) { + throw new Error(`resource not created, ${post.status} - ${post.statusText}`); + } + + const { id } = (await post.json()) as { id: string }; + + res.json({ taskId: id }); + } catch(err) { + logger.error(err); + res.status(500); + res.render('error', { error: err }) + } + }); + + router.get('/tasks/:taskId/eventstream', (req, res) => { + const { taskId } = req.params; + + const eventStreamUrl = `${scaffolderUrl}/${encodeURIComponent(taskId)}/eventstream` + + req.pipe(request(eventStreamUrl)).pipe(res); + }); + + router.use('/repositories', Repositories({ + getComponentRef, + platform, + catalog + })); + + router.use('/logs', Logs({ + getComponentRef, + platform, + catalog + })); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/platform-backend/src/service/routes/logs.ts b/plugins/platform-backend/src/service/routes/logs.ts new file mode 100644 index 0000000000..6dc9ed53d5 --- /dev/null +++ b/plugins/platform-backend/src/service/routes/logs.ts @@ -0,0 +1,51 @@ +import type { CatalogClient } from '@backstage/catalog-client'; +import Router from 'express-promise-router'; +import express from 'express'; +import { GetComponentRef, PlatformApi } from '../../types'; + +interface RouteOptions { + platform: PlatformApi; + catalog: CatalogClient; + getComponentRef: GetComponentRef; +} + +type Route = (options: RouteOptions) => express.Router; + +export const Logs: Route = ({ platform, getComponentRef }) => { + const router = Router(); + + router.get('/:component', async (req, response) => { + // Mandatory headers and http status to keep connection open + response.writeHead(200, { + 'Connection': 'keep-alive', + 'Cache-Control': 'no-cache', + 'Content-Type': 'text/event-stream', + }); + + let closed = false; + + req.on('close', () => { + closed = true; + }); + + let ref = await getComponentRef(req.params.component); + for await (const line of platform.getLogs(ref, "Development")) { + if (closed) { + return; + } else { + response.write(`${line}\n`); + flush(response); + } + } + }); + + + return router; +} + +function flush(response: express.Response) { + const flushable = response as unknown as { flush: Function }; + if (typeof flushable.flush === 'function') { + flushable.flush(); + } +} diff --git a/plugins/platform-backend/src/service/routes/repositories.ts b/plugins/platform-backend/src/service/routes/repositories.ts new file mode 100644 index 0000000000..24eea9223a --- /dev/null +++ b/plugins/platform-backend/src/service/routes/repositories.ts @@ -0,0 +1,52 @@ +import type { CatalogClient } from '@backstage/catalog-client'; +import CliTable3 from 'cli-table3'; +import chalk from 'chalk'; +import Router from 'express-promise-router'; +import express from 'express'; +import { GetComponentRef, PlatformApi } from '../../types'; + +interface RouteOptions { + platform: PlatformApi; + catalog: CatalogClient; + getComponentRef: GetComponentRef; +} + +type Route = (options: RouteOptions) => express.Router; + +export const Repositories: Route = ({ platform, getComponentRef }) => { + const router = Router(); + + router.get('/', async (req, res) => { + const repositories = await platform.getRepositories(); + + if (req.accepts('json')) { + res.json(repositories); + } else { + const table = new CliTable3({ + head: ['Component', 'Repository URL', 'Description'] + }); + table.push( + ...repositories.items.map(({ value: r }) => ([r.componentRef, r.url, r.description])) + ) + res.send(`\n${chalk.bold(' 🥁 Available Repositories')}\n${table}`) + } + + return router; + }); + + router.get('/:component/urls', async (req, res) => { + const name = req.params.component; + + const ref = await getComponentRef(name); + const urls = await platform.getRepositoryUrls(ref); + + if (urls) { + res.json(urls) + } else { + res.sendStatus(404); + res.send("Not Found"); + } + }); + + return router; +} diff --git a/plugins/platform-backend/src/setupTests.ts b/plugins/platform-backend/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/plugins/platform-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/plugins/platform-backend/src/types.ts b/plugins/platform-backend/src/types.ts new file mode 100644 index 0000000000..7e03c38db2 --- /dev/null +++ b/plugins/platform-backend/src/types.ts @@ -0,0 +1,54 @@ +import type { Entity, CompoundEntityRef } from '@backstage/catalog-model'; + +export interface Environment { + id: string; + name: string; + type: string; + url: string; +} + +export interface Repository { + componentRef: string; + slug: string; + description?: string; + url: string; +} + +export interface RepositoryUrls { + ssh: string; + https: string; +} + +export interface PlatformApi { + getLogs(ref: EntityRef, environment: string): AsyncIterable; + getEnvironments(ref: EntityRef, page?: PageSpec): Promise>; + getRepositories(page?: PageSpec): Promise> + getRepositoryUrls(ref: EntityRef): Promise +} + +export interface EntityRef { + ref: string; + compound: CompoundEntityRef; + load(): Promise; +} + +export interface Page { + hasNextPage: boolean; + hasPreviousPage: boolean; + beginCursor: string; + endCursor: string; + items: { + cursor: string; + value: T; + }[]; +} + +export type PageSpec = { + count: number; + before: string; +} | { + count: number; + after: string; +} + +export type GetComponentRef = (name: string) => Promise diff --git a/plugins/platform/.eslintrc.js b/plugins/platform/.eslintrc.js new file mode 100644 index 0000000000..86d582272c --- /dev/null +++ b/plugins/platform/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + rules: { + 'no-else-return': 'off', + 'prefer-const': 'off', + } +}); diff --git a/plugins/platform/README.md b/plugins/platform/README.md new file mode 100644 index 0000000000..319126d862 --- /dev/null +++ b/plugins/platform/README.md @@ -0,0 +1,13 @@ +# platform + +Welcome to the platform plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/platform](http://localhost:3000/platform). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/platform/dev/index.tsx b/plugins/platform/dev/index.tsx new file mode 100644 index 0000000000..d708d5f6e6 --- /dev/null +++ b/plugins/platform/dev/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { platformPlugin, PlatformPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(platformPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/platform' + }) + .render(); diff --git a/plugins/platform/package.json b/plugins/platform/package.json new file mode 100644 index 0000000000..73686506ad --- /dev/null +++ b/plugins/platform/package.json @@ -0,0 +1,54 @@ +{ + "name": "@frontside/backstage-plugin-platform", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "^0.11.2", + "@backstage/core-plugin-api": "^1.0.3", + "@backstage/theme": "^0.2.15", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "ansi-to-react": "^6.1.6", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.20.0", + "@backstage/core-app-api": "^1.0.3", + "@backstage/dev-utils": "^1.0.1", + "@backstage/test-utils": "^1.1.1", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/jest": "*", + "@types/node": "*", + "cross-fetch": "^3.1.5", + "msw": "^0.42.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/platform/src/api/executables-api.ts b/plugins/platform/src/api/executables-api.ts new file mode 100644 index 0000000000..d7fb5200ad --- /dev/null +++ b/plugins/platform/src/api/executables-api.ts @@ -0,0 +1,10 @@ +import type { DownloadInfo } from '@frontside/backstage-plugin-platform-backend'; +import { createApiRef } from '@backstage/core-plugin-api'; + +export const executablesApiRef = createApiRef({ + id: 'plugin.platform.executables', +}); + +export interface ExecutablesAPI { + fetchExecutables(): Promise; +} diff --git a/plugins/platform/src/components/AllExecutables/AllExecutables.tsx b/plugins/platform/src/components/AllExecutables/AllExecutables.tsx new file mode 100644 index 0000000000..262f256d28 --- /dev/null +++ b/plugins/platform/src/components/AllExecutables/AllExecutables.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Table, TableColumn, Progress } from '@backstage/core-components'; +import Alert from '@material-ui/lab/Alert'; +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; + +import type { DownloadInfo } from '@frontside/backstage-plugin-platform-backend'; +import { executablesApiRef } from '../../api/executables-api'; + +export const DenseTable = ({ info }: { info: DownloadInfo}) => { + + const columns: TableColumn[] = [ + { title: 'Architecture', field: 'target' }, + { title: 'Status', field: 'status' }, + { title: 'URL', field: 'url' }, + ]; + + let { executableName, executables } = info; + + const data = Object.entries(executables).map(([target, executable]) => { + return { + id: target, + target, + url: executable.type === 'compiled' ? executable.url : 'N/A', + status: executable.type, + }; + }); + + return ( + + ); +}; + +export const AllExecutables = () => { + let api = useApi(executablesApiRef); + const { value, loading, error } = useAsync(api.fetchExecutables, []); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } else { + return ; + } + +}; diff --git a/plugins/platform/src/components/AllExecutables/index.ts b/plugins/platform/src/components/AllExecutables/index.ts new file mode 100644 index 0000000000..f376585c64 --- /dev/null +++ b/plugins/platform/src/components/AllExecutables/index.ts @@ -0,0 +1 @@ +export { AllExecutables } from './AllExecutables'; diff --git a/plugins/platform/src/components/Install/Install.tsx b/plugins/platform/src/components/Install/Install.tsx new file mode 100644 index 0000000000..8f3c23ed68 --- /dev/null +++ b/plugins/platform/src/components/Install/Install.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Typography, Grid } from '@material-ui/core'; +import { + InfoCard, + Header, + Page, + Content, + ContentHeader, + HeaderLabel, + SupportButton, +} from '@backstage/core-components'; +import { AllExecutables } from '../AllExecutables'; +import { executablesApiRef } from '../../api/executables-api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import Ansi from 'ansi-to-react'; + +export const useHelp = () => { + let api = useApi(executablesApiRef); + let info = useAsync(api.fetchExecutables, []); + if (info.loading) { + return { loading: true }; + } else if (info.error) { + return info; + } else if (info.value?.helpText.type === 'rejected' ) { + return { loading: false, error: info.value.helpText.error }; + } else if (info.value?.helpText.type === 'resolved') { + return { loading: false, value: info.value?.helpText.value }; + } else { + return { loading: true }; + } +} + +export const Install = () => { + let helpText = useHelp(); + return ( + +
+ + +
+ + + Install your company's platform tool + + + + + + curl -sSL http://localhost:7007/api/idp/install.sh | sh + + + + + + + {helpText.error ? "" : helpText.value } + + + + + + + + +
+ ) +}; diff --git a/plugins/platform/src/components/Install/index.ts b/plugins/platform/src/components/Install/index.ts new file mode 100644 index 0000000000..2c54307650 --- /dev/null +++ b/plugins/platform/src/components/Install/index.ts @@ -0,0 +1 @@ +export { Install } from './Install'; diff --git a/plugins/platform/src/index.ts b/plugins/platform/src/index.ts new file mode 100644 index 0000000000..ead53f1314 --- /dev/null +++ b/plugins/platform/src/index.ts @@ -0,0 +1 @@ +export { platformPlugin, PlatformPage } from './plugin'; diff --git a/plugins/platform/src/plugin.test.ts b/plugins/platform/src/plugin.test.ts new file mode 100644 index 0000000000..ba829a47f1 --- /dev/null +++ b/plugins/platform/src/plugin.test.ts @@ -0,0 +1,7 @@ +import { platformPlugin } from './plugin'; + +describe('platform', () => { + it('should export plugin', () => { + expect(platformPlugin).toBeDefined(); + }); +}); diff --git a/plugins/platform/src/plugin.ts b/plugins/platform/src/plugin.ts new file mode 100644 index 0000000000..1b196b93dc --- /dev/null +++ b/plugins/platform/src/plugin.ts @@ -0,0 +1,34 @@ +import { createApiFactory, createPlugin, discoveryApiRef, createRoutableExtension } from '@backstage/core-plugin-api'; +import { ExecutablesAPI, executablesApiRef } from './api/executables-api' +import { rootRouteRef } from './routes'; + +export const platformPlugin = createPlugin({ + id: 'platform', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: executablesApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory({ discoveryApi, }) { + return { + async fetchExecutables() { + let baseUrl = await discoveryApi.getBaseUrl('idp'); + let response = await fetch(`${baseUrl}/executables`); + return await response.json(); + } + } as ExecutablesAPI; + } + }) + ] +}); + +export const PlatformPage = platformPlugin.provide( + createRoutableExtension({ + name: 'PlatformPage', + component: () => + import('./components/Install').then(m => m.Install), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/platform/src/routes.ts b/plugins/platform/src/routes.ts new file mode 100644 index 0000000000..d6b3157ddd --- /dev/null +++ b/plugins/platform/src/routes.ts @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'platform', +}); diff --git a/plugins/platform/src/setupTests.ts b/plugins/platform/src/setupTests.ts new file mode 100644 index 0000000000..48c09b5346 --- /dev/null +++ b/plugins/platform/src/setupTests.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/tsconfig.json b/tsconfig.json index fda6cf4884..d6ce168934 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,10 @@ "plugins/*/dev", "plugins/*/migrations" ], - "exclude": ["node_modules", "packages/graphgen"], + "exclude": [ + "node_modules", + "packages/graphgen" + ], "compilerOptions": { "outDir": "dist-types", "rootDir": "." diff --git a/yarn.lock b/yarn.lock index 6c4591b299..423d3c09a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1932,7 +1932,7 @@ yaml "^2.0.0" yup "^0.32.9" -"@backstage/config@^1.0.3": +"@backstage/config@^1.0.1", "@backstage/config@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@backstage/config/-/config-1.0.3.tgz#9b27661c11b010fce974d25519c57fe28004d4a1" integrity sha512-KFxtGnqTFhN9zS90SLSTcDgJpsSd3IvqfeYLN8IvqfwG2lzaYCRxWRK3XlwOe2PY6Lv98YlMEWfNw5DKw46sgA== @@ -1940,7 +1940,7 @@ "@backstage/types" "^1.0.0" lodash "^4.17.21" -"@backstage/core-app-api@^1.1.1": +"@backstage/core-app-api@^1.0.3", "@backstage/core-app-api@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@backstage/core-app-api/-/core-app-api-1.1.1.tgz#4217ca4b2e30187f5c8f7d0c74115ba52e6f5282" integrity sha512-rCR9llaPeB2POEphKUfwEMSOStTdrgyC+qozKdNvfLw0hcK6vewtZL0mhy8gy2gShybEzLvU4lEdPzeAIKDJBg== @@ -1998,7 +1998,7 @@ zen-observable "^0.8.15" zod "^3.11.6" -"@backstage/core-plugin-api@^1.0.7": +"@backstage/core-plugin-api@^1.0.3", "@backstage/core-plugin-api@^1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.0.7.tgz#2c02593bb8c3a2d5aa645780a8b7c2a97744d914" integrity sha512-bti6TT4CfJPzQXb6p7kJI+NhvaGkiLzcHja/I4JJTOzkKmw6++e0raGDyRt9VybILRCvFgnXHEUkWehRZMu74A== @@ -2010,7 +2010,7 @@ prop-types "^15.7.2" zen-observable "^0.8.15" -"@backstage/dev-utils@^1.0.7": +"@backstage/dev-utils@^1.0.1", "@backstage/dev-utils@^1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@backstage/dev-utils/-/dev-utils-1.0.7.tgz#437ec9dd371f476c4d31b5e72d2f601992e96dfa" integrity sha512-nzngb8dM/+wyccOhyni0Jh74peFXSxB5V/goB/9DTh5iZeou+5ivA5ys8wxy72rmkTtwkxWVwPBRCQFvh3mRxA== @@ -2842,7 +2842,7 @@ dependencies: cross-fetch "^3.1.5" -"@backstage/test-utils@^1.2.1": +"@backstage/test-utils@^1.1.1", "@backstage/test-utils@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@backstage/test-utils/-/test-utils-1.2.1.tgz#033366564b44d43b9100a228c4af3c9612f7d00b" integrity sha512-YE8rOcb1nQ1EFWcChr2Ld4NNMSHKqU2HfnH7E2voPV0ZkebhEEVRa2tl3apaHGvN5hhLvNvt9YWb8vvP3Y65dA== @@ -2862,7 +2862,7 @@ cross-fetch "^3.1.5" zen-observable "^0.8.15" -"@backstage/theme@^0.2.16": +"@backstage/theme@^0.2.15", "@backstage/theme@^0.2.16": version "0.2.16" resolved "https://registry.yarnpkg.com/@backstage/theme/-/theme-0.2.16.tgz#463abce6f55e160a3a61e6654603f20b4f259a9e" integrity sha512-UDVqQhPunL3uDrhhKP72HlvEoG3rv2dspPxCEGqAAICBjXdLGh7CZ2qGlwdBxDjvCZ/tJcg9GNfpa6SOzdMJmA== @@ -3269,6 +3269,15 @@ fp-ts "^2.8.2" monocle-ts "^2.3.3" +"@effection/channel@2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@effection/channel/-/channel-2.0.3.tgz#825ade1a4a09b860efdf7077fc02f81d6c7614bb" + integrity sha512-HZE2q7dtErIur0g+BVMPqa+dVBgrIIaYMrzMBNM1UoIB6urMGgr+uPWXwgQb3Vzm4Il9SCiCzoM3RE3gDiV1Ig== + dependencies: + "@effection/core" "2.2.0" + "@effection/events" "2.0.3" + "@effection/stream" "2.0.3" + "@effection/channel@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@effection/channel/-/channel-2.0.4.tgz#f8fcb82d2fe402403b1479d0e3f2d4916da7d4ad" @@ -3278,6 +3287,13 @@ "@effection/events" "2.0.4" "@effection/stream" "2.0.4" +"@effection/core@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@effection/core/-/core-2.2.0.tgz#4d11d7948144aecd70a26daf8abaa29ee89bc259" + integrity sha512-1RBMrDS0Ya02NEM0TQQRwzlGDSZmwoHhuD3qmWp9NLjZowhO1gJBZ16fQL2NbKvcpS71xho+oZsDedId+C1q8Q== + dependencies: + abort-controller "^3.0.0" + "@effection/core@2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@effection/core/-/core-2.2.1.tgz#5115fa14158c10d2d0693f524033fdf356f72083" @@ -3292,6 +3308,14 @@ dependencies: effection "2.0.6" +"@effection/events@2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@effection/events/-/events-2.0.3.tgz#cf212748f8e433dcf776e5e1dd0145716213a7bc" + integrity sha512-x8NBNXHZxI4SJ/db1zy7zs6BRtMIKu8NgymUMpbyrRdapPSIu6rmf4WgXyWrk1uvQPSViEkxOXPw8B2MLu/YnA== + dependencies: + "@effection/core" "2.2.0" + "@effection/stream" "2.0.3" + "@effection/events@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@effection/events/-/events-2.0.4.tgz#82173e7e9262ba8746c0c36d37f3f8d9c6d0d2c1" @@ -3300,6 +3324,14 @@ "@effection/core" "2.2.1" "@effection/stream" "2.0.4" +"@effection/fetch@2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@effection/fetch/-/fetch-2.0.4.tgz#8f76f0b630b3974ef267bd8803599448db956cda" + integrity sha512-IhUYqSAM0stEB6VCWK9Mz8F56jWFFpM9yQ4boxGs4F/sdoHnY/9KKW1zxAY05jyYPUHxuUky7sazZVFJm5xRmw== + dependencies: + "@effection/core" "2.2.0" + cross-fetch "3.1.5" + "@effection/fetch@2.0.5": version "2.0.5" resolved "https://registry.yarnpkg.com/@effection/fetch/-/fetch-2.0.5.tgz#786c73e2c327aed37e1e010691d0090fc06ee5dd" @@ -3336,6 +3368,15 @@ assert-ts "^0.3.4" effection "2.0.6" +"@effection/main@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@effection/main/-/main-2.1.0.tgz#90a691d1a78e17ec27ba7ff7e5a87894182c2876" + integrity sha512-jnAlVjsLy1feJeNBLHwmA/mDpnoJsYO3gGtAg2XALS4EiIc7nhNDeoj9D6bsBxqUHHEp2FYupYztFK0vU11UFA== + dependencies: + "@effection/core" "2.2.0" + chalk "^4.1.2" + stacktrace-parser "^0.1.10" + "@effection/main@2.1.1": version "2.1.1" resolved "https://registry.yarnpkg.com/@effection/main/-/main-2.1.1.tgz#c47225081130d11092fa92b2fafa14362c1cf307" @@ -3345,6 +3386,16 @@ chalk "^4.1.2" stacktrace-parser "^0.1.10" +"@effection/process@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@effection/process/-/process-2.1.1.tgz#bf48a884faa06b8004c473065c385ab3939e6439" + integrity sha512-VNRbRCKwbP48iZcDB66pH4oe77dmffdeQVnLHFth2HIPfJisGH0vTE/3aAQ+1KDaJImWXtq4xOTaurjwqYPHEg== + dependencies: + cross-spawn "^7.0.3" + ctrlc-windows "^2.1.0" + effection "2.0.5" + shellwords "^0.1.1" + "@effection/process@^2.1.2": version "2.1.2" resolved "https://registry.yarnpkg.com/@effection/process/-/process-2.1.2.tgz#adc473ad9ed0916df3c34e0bba1f697ac29afb50" @@ -3362,6 +3413,14 @@ dependencies: effection "2.0.6" +"@effection/stream@2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@effection/stream/-/stream-2.0.3.tgz#c1610c63dfe6d10b0b6aeda6969fea24018364b0" + integrity sha512-l1A8PUfxR04eyUBOD1H5gdCu4U5OMwUh2TB/O/IeUlfVoP9tg64daTu7zpZGij9uDcDWV5IP9LJYoWe2lVGbKg== + dependencies: + "@effection/core" "2.2.0" + "@effection/subscription" "2.0.3" + "@effection/stream@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@effection/stream/-/stream-2.0.4.tgz#7fec4edaf0db13d604dcd99997745177760b2bdf" @@ -3370,6 +3429,13 @@ "@effection/core" "2.2.1" "@effection/subscription" "2.0.4" +"@effection/subscription@2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@effection/subscription/-/subscription-2.0.3.tgz#ab6e56bf52663b769eb00d0022195b2e753a127d" + integrity sha512-P+bAh0iqCduvzAM+0hbn29HJ+J4TT+lkJTDydw3tI6lSe/OVX9+FJhX/zx2QW3APjFpseMJphjRiEyzf21b/Xw== + dependencies: + "@effection/core" "2.2.0" + "@effection/subscription@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@effection/subscription/-/subscription-2.0.4.tgz#ea4cabc747f4c050311b140b7e558e471ece77bd" @@ -6933,6 +6999,11 @@ "@types/node" "*" "@types/responselike" "*" +"@types/caseless@*": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" + integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== + "@types/connect-history-api-fallback@^1.3.5": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz#d1f7a8a09d0ed5a57aee5ae9c18ab9b803205dae" @@ -7322,6 +7393,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/nunjucks@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/nunjucks/-/nunjucks-3.2.1.tgz#02a3ade3dc4d3950029c6466a4034565dba7cf8c" + integrity sha512-hUh5HIC7peH+0MvlYU5KM2RydWxG1mBceivHsQGwlelU9zlczLICyJmjMwgjkI3m0+N50n46GVHkw35lIim6LQ== + "@types/oauth@*": version "0.9.1" resolved "https://registry.yarnpkg.com/@types/oauth/-/oauth-0.9.1.tgz#e17221e7f7936b0459ae7d006255dff61adca305" @@ -7424,6 +7500,16 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/request@^2.48.8": + version "2.48.8" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.8.tgz#0b90fde3b655ab50976cb8c5ac00faca22f5a82c" + integrity sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ== + dependencies: + "@types/caseless" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + form-data "^2.5.0" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -8000,6 +8086,11 @@ address@^1.0.1, address@^1.1.2: resolved "https://registry.yarnpkg.com/address/-/address-1.2.0.tgz#d352a62c92fee90f89a693eccd2a8b2139ab02d9" integrity sha512-tNEZYz5G/zYunxFm7sfhAxkXEuLj3K6BKwv6ZURlsF6yiUQ65z0Q2wZW9L5cPUl9ocofGvXOdFYbFHp0+6MOig== +adm-zip@^0.5.4: + version "0.5.9" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.9.tgz#b33691028333821c0cf95c31374c5462f2905a83" + integrity sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -8068,6 +8159,11 @@ alea@1.0.1: resolved "https://registry.yarnpkg.com/alea/-/alea-1.0.1.tgz#957f60741c5ad11b13f72aa02a6b89fe96a26dc4" integrity sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA== +anser@^1.4.1: + version "1.4.10" + resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.10.tgz#befa3eddf282684bd03b63dcda3927aef8c2e35b" + integrity sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww== + ansi-colors@^4.1.1, ansi-colors@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -8124,6 +8220,14 @@ ansi-styles@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== +ansi-to-react@^6.1.6: + version "6.1.6" + resolved "https://registry.yarnpkg.com/ansi-to-react/-/ansi-to-react-6.1.6.tgz#d6fe15ecd4351df626a08121b1646adfe6c02ccb" + integrity sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q== + dependencies: + anser "^1.4.1" + escape-carriage "^1.3.0" + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -9446,7 +9550,7 @@ cli-spinners@^2.5.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d" integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g== -cli-table3@~0.6.1: +cli-table3@^0.6.3, cli-table3@~0.6.1: version "0.6.3" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== @@ -10779,6 +10883,14 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== +deno-bin@^1.26.0: + version "1.26.0" + resolved "https://registry.yarnpkg.com/deno-bin/-/deno-bin-1.26.0.tgz#03158f4d75950866051e81f7a98d30740b51b958" + integrity sha512-4RIh4Igx2F4E4EGttAerFV35QIzyHB9fzw4tTBTEUKHyom/Kuj85WoHDggBvoPliAAd+xDk7fJT9dyLu1w4EpQ== + dependencies: + adm-zip "^0.5.4" + follow-redirects "^1.10.0" + denque@^2.0.1: version "2.1.0" resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" @@ -11119,6 +11231,19 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== +effection@2.0.5, effection@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/effection/-/effection-2.0.5.tgz#caac782994f8f69644bac3eda32228d8799dd244" + integrity sha512-q+5iex8LMWP3kitokhkCQErDIN1awRHy7MVqsDIweGeTl0rgpX4Y1KDjE4onvnPF9JtyvhOEFpAAUMRnqi0wqg== + dependencies: + "@effection/channel" "2.0.3" + "@effection/core" "2.2.0" + "@effection/events" "2.0.3" + "@effection/fetch" "2.0.4" + "@effection/main" "2.1.0" + "@effection/stream" "2.0.3" + "@effection/subscription" "2.0.3" + effection@2.0.6, effection@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/effection/-/effection-2.0.6.tgz#a2e5f8dc3ac0751882bb595f73ccb14c7bdc09b6" @@ -11623,6 +11748,11 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== +escape-carriage@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/escape-carriage/-/escape-carriage-1.3.0.tgz#71006b2d4da8cb6828686addafcb094239c742f3" + integrity sha512-ATWi5MD8QlAGQOeMgI8zTp671BG8aKvAC0M7yenlxU4CRLGO/sKthxVUyjiOFKjHdIo+6dZZUNFgHFeVEaKfGQ== + escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -12477,7 +12607,7 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== -follow-redirects@^1.14.9: +follow-redirects@^1.10.0, follow-redirects@^1.14.9: version "1.15.2" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== @@ -12541,7 +12671,7 @@ form-data-encoder@^1.4.3, form-data-encoder@^1.7.1: resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== -form-data@^2.3.1, form-data@^2.3.2: +form-data@^2.3.1, form-data@^2.3.2, form-data@^2.5.0: version "2.5.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== @@ -17495,11 +17625,25 @@ node-cache@^5.1.2: dependencies: clone "2.x" +node-deno@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/node-deno/-/node-deno-0.1.0.tgz#b70a9307d93db03bca69da0377ad3b396f4486e0" + integrity sha512-RPwAQZQHaadrejl83mDOq0LbNgefveH+QYOT1r5IUKmmqc/hKizNSt0xlWEDCy+Hvb5pCurk75FGK5XFb3eD0g== + dependencies: + "@effection/process" "^2.1.1" + deno-bin "^1.26.0" + effection "^2.0.5" + node-domexception@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== +node-fetch-native@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-0.1.7.tgz#8a8ed0d5d1d1b89d34c6731a9d69d407c09df067" + integrity sha512-hps7dFJM0IEF056JftDSSjWDAwW9v2clwHoUJiHyYgl+ojoqjKyWybljMlpTmlC1O+864qovNlRLyAIjRxu9Ag== + node-fetch@2.6.7, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"