feat(odo): create odo workspace and plugins

Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
Paul Schultz 2024-07-09 14:23:51 -05:00
parent ca0e201e1e
commit bcfbc5c7c0
36 changed files with 27896 additions and 0 deletions

View File

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@ -0,0 +1,10 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch"
}

View File

@ -0,0 +1,8 @@
.git
.yarn/cache
.yarn/install-state.gz
node_modules
packages/*/src
packages/*/node_modules
plugins
*.local.yaml

View File

@ -0,0 +1 @@
playwright.config.ts

View File

@ -0,0 +1,3 @@
module.exports = {
root: true,
};

54
workspaces/odo/.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# macOS
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Coverage directory generated when running tests with coverage
coverage
# Dependencies
node_modules/
# Yarn 3 files
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Node version directives
.nvmrc
# dotenv environment variables file
.env
.env.test
# Build output
dist
dist-types
# Temporary change files created by Vim
*.swp
# MkDocs build output
site
# Local configuration files
*.local.yaml
# Sensitive credentials
*-credentials.yaml
# vscode database functionality support files
*.session.sql
# E2E test reports
e2e-test-report/

View File

@ -0,0 +1,5 @@
dist
dist-types
coverage
.vscode
.eslintrc.js

13
workspaces/odo/README.md Normal file
View File

@ -0,0 +1,13 @@
# backstage-odo-devfile-plugin
[odo](https://odo.dev/) and [Devfile](https://devfile.io/) integration within Backstage.
This repository contains a set of extensions for Backstage that can be used to write your own [Software Templates](https://backstage.io/docs/features/software-templates/):
- [devfile-field-extension](plugins/devfile-field-extension): a [Custom Field Extension](https://backstage.io/docs/features/software-templates/writing-custom-field-extensions/) that allows you to add a set of drop-down lists to pick a Devfile Stack version, a version, and a starter project.
- [scaffolder-odo-actions-backend](plugins/scaffolder-odo-actions-backend): a [Backend Plugin](https://backstage.io/docs/plugins/backend-plugin/) containing a set of [Custom Actions](https://backstage.io/docs/features/software-templates/writing-custom-actions) using the [`odo`](https://odo.dev/) CLI.
## Example
See <https://github.com/ododev/odo-backstage-software-template> for an end-to-end example of a [Backstage Software Template](https://backstage.io/docs/features/software-templates/) relying on the extensions above.

View File

@ -0,0 +1,3 @@
{
"version": "1.28.4"
}

View File

@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: odo
description: An example of a Backstage application.
# Example for optional annotations
# annotations:
# github.com/project-slug: backstage/backstage
# backstage.io/techdocs-ref: dir:.
spec:
type: website
owner: john@example.com
lifecycle: experimental

View File

@ -0,0 +1,58 @@
{
"name": "@internal/odo",
"version": "1.0.0",
"private": true,
"engines": {
"node": "18 || 20"
},
"scripts": {
"tsc": "tsc",
"tsc:full": "tsc --skipLibCheck false --incremental false",
"build:all": "backstage-cli repo build --all",
"build:api-reports": "yarn build:api-reports:only --tsc",
"build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type --validate-release-tags",
"clean": "backstage-cli repo clean",
"test": "backstage-cli repo test",
"test:all": "backstage-cli repo test --coverage",
"fix": "backstage-cli repo fix",
"lint": "backstage-cli repo lint --since origin/main",
"lint:all": "backstage-cli repo lint",
"prettier:check": "prettier --check .",
"new": "backstage-cli new --scope @backstage-community"
},
"workspaces": {
"packages": [
"packages/*",
"plugins/*"
]
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/odo"
},
"devDependencies": {
"@backstage/cli": "^0.26.7",
"@backstage/e2e-test-utils": "^0.1.1",
"@backstage/repo-tools": "^0.8.0",
"@changesets/cli": "^2.27.1",
"@spotify/prettier-config": "^12.0.0",
"node-gyp": "^9.0.0",
"prettier": "^2.3.2",
"typescript": "~5.3.0"
},
"resolutions": {
"@types/react": "^18",
"@types/react-dom": "^18"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx,mjs,cjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}

View File

@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);

View File

@ -0,0 +1,85 @@
# devfile-field-extension
This plugin is a Custom Field Extension for Backstage. It allows you to add a set of drop-down lists to pick a Devfile Stack version, a version, and a starter project.
It interacts with the configured [Devfile registry](https://registry.devfile.io/viewer) to load the list of available Devfile Stacks.
## Preview
![Devfile Custom Field Extension Preview](https://github.com/rm3l/backstage-odo-devfile-plugin/assets/593208/e7fcc998-dc87-4603-977b-76f510fed3aa)
## Installation
From your Backstage instance root folder:
```shell
yarn add --cwd packages/app @backstage-community/plugin-odo-module-devfile-field-extension
```
## Configuration
1. Add the import to your `packages/app/src/App.tsx` on the frontend package of your Backstage instance:
```tsx
// packages/app/src/App.tsx
import { DevfileSelectorFieldExtension } from '@backstage-community/plugin-odo-module-devfile-field-extension';
import { ScaffolderFieldExtensions } from '@backstage/plugin-scaffolder-react';
```
2. Then add the imported field extension as a child of `ScaffolderFieldExtensions` inside `Route`, like so:
```tsx
// packages/app/src/App.tsx
<Route path="/create" element={<ScaffolderPage />}>
<ScaffolderFieldExtensions>
<DevfileSelectorFieldExtension />
</ScaffolderFieldExtensions>
</Route>
```
3. This custom field extension populates the dropdown lists from a Devfile Registry, which is expected to be proxied by Backstage at the following path: `/devfile-registry`. As such, you need to add the appropriate configuration to your `app-config.yaml` file under the `proxy.endpoints` field, like so:
```yaml
# app-config.yaml
proxy:
endpoints:
'/devfile-registry':
target: 'https://registry.devfile.io'
```
You should now see the custom field `DevfileSelectorExtension` and its dropdown lists populated if you navigate to the Template Editor page at <http://localhost:3000/create/edit>.
## Usage
To use the extension in a Backstage Software Template, you can add the `ui:field` field to the parameter. The output of the extension is an object made up of the following fields:
- `devfile`: the Devfile Stack selected by the user.
- `version`: the Devfile Stack version selected by the user
- `starter_project`: the Devfile Starter Project selected by the user. Optional.
```yaml
parameters:
- title: Provide details about the Devfile
required:
- devfile_data
properties:
devfile_data:
type: object
required:
- devfile
- version
properties:
devfile:
type: string
version:
type: string
starter_project:
type: string
ui:field: DevfileSelectorExtension
ui:autofocus: true
ui:options:
rows: 5
```
See <https://github.com/ododev/odo-backstage-software-template> for an example of a [Software Template](https://backstage.io/docs/features/software-templates/) making use of the Devfile Selector Field Extension.

View File

@ -0,0 +1,10 @@
import React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { devfileSelectorExtensionPlugin, DevfileSelectorFieldExtension } from '../src/plugin';
createDevApp()
.registerPlugin(devfileSelectorExtensionPlugin)
.addPage({
element: <DevfileSelectorFieldExtension />,
})
.render();

View File

@ -0,0 +1,53 @@
{
"name": "@backstage-community/plugin-odo-module-devfile-field-extension",
"version": "0.21.0",
"description": "The devfile modules for odo",
"author": "Red Hat",
"homepage": "https://odo.dev",
"backstage": {
"role": "frontend-plugin"
},
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/odo/plugins/odo-module-devfile-field-extension"
},
"main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"license": "Apache-2.0",
"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-plugin-api": "^1.9.3",
"@backstage/plugin-scaffolder": "^1.22.0",
"@backstage/plugin-scaffolder-react": "^1.9.0",
"@material-ui/core": "^4.12.4",
"@material-ui/lab": "^4.0.0-alpha.61",
"react-use": "^17.5.0",
"zod": "^3.23.8"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.26.10",
"@backstage/dev-utils": "^1.0.34"
},
"files": [
"dist",
"package.json",
"README.md"
]
}

View File

@ -0,0 +1,255 @@
import React, { useState } from "react";
import { FormControl, TextField } from "@material-ui/core";
import { z } from "zod";
import { makeFieldSchemaFromZod } from "@backstage/plugin-scaffolder";
import { useAsync } from "react-use";
import Autocomplete from "@material-ui/lab/Autocomplete";
import { makeStyles } from "@material-ui/core/styles";
import { useApi, configApiRef } from "@backstage/core-plugin-api";
const DevfileSelectorExtensionWithOptionsFieldSchema = makeFieldSchemaFromZod(
z.object({
devfile: z.string().describe("Devfile name"),
version: z.string().describe("Devfile Stack version"),
starter_project: z
.string()
.optional()
.describe("Devfile Stack starter project"),
})
);
export const DevfileSelectorExtensionWithOptionsSchema =
DevfileSelectorExtensionWithOptionsFieldSchema.schema;
type DevfileSelectorExtensionWithOptionsProps =
typeof DevfileSelectorExtensionWithOptionsFieldSchema.type;
export interface DevfileStack {
name: string;
displayName: string | undefined;
icon: string;
versions: DevfileStackVersion[];
}
export interface DevfileStackVersion {
version: string;
starterProjects: string[];
}
const useStyles = makeStyles({
option: {
fontSize: 15,
"& > span": {
marginRight: 10,
fontSize: 18,
},
},
});
export const DevfileSelectorExtension = ({
onChange,
rawErrors,
required,
formData,
idSchema,
schema: { description },
}: DevfileSelectorExtensionWithOptionsProps) => {
const config = useApi(configApiRef);
const [loading, setLoading] = useState(true);
const [data, setData] = useState<DevfileStack[]>([]);
const [selectedStack, setSelectedStack] = useState("");
const [versions, setVersions] = useState<string[]>([]);
const [selectedVersion, setSelectedVersion] = useState("");
const [starterprojects, setStarterprojects] = useState<string[]>([]);
const backendUrl = config.getString("backend.baseUrl");
// This requires a proxy endpoint to be added for /devfile-registry
const registryApiEndpoint = `${backendUrl}/api/proxy/devfile-registry/v2index`;
useAsync(async () => {
const req = await fetch(registryApiEndpoint, {
headers: {
Accept: "application/json",
},
});
const resp = (await req.json()) as DevfileStack[];
resp.sort((a, b) =>
(a.displayName ?? "").localeCompare(b.displayName ?? "")
);
setData(resp);
setVersions([]);
setStarterprojects([]);
setLoading(false);
});
const handleDevfileStack = (value: DevfileStack) => {
const filteredStacks = data.filter((stack) => stack.name === value?.name);
const versionList = filteredStacks.flatMap((stack) => stack.versions);
const filteredVersions = versionList.map((v) => v.version);
filteredVersions.sort((a, b) => a.localeCompare(b));
let filteredStarterProjects: string[] = [];
if (versionList.length > 0) {
filteredStarterProjects = versionList[0].starterProjects ?? [];
}
setSelectedStack(value.name);
setSelectedVersion(filteredVersions?.length > 0 ? filteredVersions[0] : "");
setVersions(filteredVersions);
setStarterprojects(filteredStarterProjects);
onChange({
devfile: value.name,
version: versionList.length > 0 ? versionList[0].version : "",
starter_project:
filteredStarterProjects.length > 0 ? filteredStarterProjects[0] : "",
});
};
const handleDevfileStackVersion = (value: any) => {
const filteredResult = data
.filter((stack) => stack.name === selectedStack)
.flatMap((stack) => stack.versions)
.filter((v) => v.version === value)
.flatMap((v) => v.starterProjects ?? []);
filteredResult.sort((a, b) => a.localeCompare(b));
setSelectedVersion(value as string);
setStarterprojects(filteredResult);
onChange({
devfile: selectedStack,
version: value as string,
starter_project: filteredResult.length > 0 ? filteredResult[0] : "",
});
};
const handleDevfileStarterProject = (value: any) => {
onChange({
devfile: selectedStack,
version: selectedVersion,
starter_project: value as string,
});
};
const classes = useStyles();
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0}
>
<div>
<Autocomplete
id={`devfile-selector-${idSchema?.$id}`}
loading={loading}
noOptionsText="No Devfile Stacks available from registry"
value={
// dummy DevfileStack object with the name set, so that getOptionSelected can resolve the right item from data
{ name: formData?.devfile ?? selectedStack, icon: "", displayName: "", versions: [] }
}
classes={{
option: classes.option,
}}
options={data}
renderOption={(option) =>
option.icon ? (
<React.Fragment>
<span>
<img
style={{ width: 50, height: 50 }}
src={option.icon}
alt={`icon for ${option.name}`}
/>
</span>
{option.displayName}
</React.Fragment>
) : (
<React.Fragment>{option.displayName}</React.Fragment>
)
}
getOptionLabel={(option) => option.name}
renderInput={(params) => (
<TextField
{...params}
label="Devfile Stack"
variant="outlined"
required={required}
error={rawErrors?.length > 0 && !formData}
inputProps={{
...params.inputProps,
autoComplete: "new-password", // disable autocomplete and autofill
}}
helperText={description}
/>
)}
onChange={(_, value) => handleDevfileStack(value)}
getOptionSelected={(option, value) => option.name === value.name}
disableClearable
/>
</div>
<br/>
<div>
<Autocomplete
id={`devfile-version-selector-${idSchema?.$id}`}
loading={loading}
value={
formData?.version ?? selectedVersion ?? (versions.length > 0 ? versions[0] : null)
}
noOptionsText="No version available in Devfile Stack"
renderInput={(params) => (
<TextField
{...params}
label="Version"
variant="outlined"
required={required}
error={rawErrors?.length > 0 && !formData}
helperText={description}
inputProps={{
...params.inputProps,
autoComplete: "new-password", // disable autocomplete and autofill
}}
/>
)}
options={versions}
onChange={(_, value) => handleDevfileStackVersion(value)}
getOptionSelected={(option, value) => option === value}
disableClearable
/>
</div>
<br/>
<div>
<Autocomplete
id={`devfile-starter-project-selector-${idSchema?.$id}`}
loading={loading}
value={
formData?.starter_project ??
(starterprojects.length > 0 ? starterprojects[0] : "")
}
noOptionsText="No starter project available in Devfile Stack"
renderInput={(params) => (
<TextField
{...params}
label="Starter Project"
variant="outlined"
required={false}
error={rawErrors?.length > 0 && !formData}
inputProps={{
...params.inputProps,
autoComplete: "new-password", // disable autocomplete and autofill
}}
helperText={description}
/>
)}
options={starterprojects}
onChange={(_, value) => handleDevfileStarterProject(value)}
getOptionSelected={(option, value) => option === value}
disableClearable
/>
</div>
</FormControl>
);
};

View File

@ -0,0 +1,4 @@
export {
devfileSelectorExtensionPlugin,
DevfileSelectorFieldExtension,
} from "./plugin";

View File

@ -0,0 +1,7 @@
import { devfileSelectorExtensionPlugin } from './plugin';
describe('plugin-scaffolder-frontend-module-devfile-field', () => {
it('should export DevfileSelector plugin', () => {
expect(devfileSelectorExtensionPlugin).toBeDefined();
});
});

View File

@ -0,0 +1,18 @@
import { createPlugin } from '@backstage/core-plugin-api';
import { scaffolderPlugin } from '@backstage/plugin-scaffolder';
import { createScaffolderFieldExtension } from '@backstage/plugin-scaffolder-react';
import { DevfileSelectorExtension, DevfileSelectorExtensionWithOptionsSchema } from './components/DevfileSelectorExtension';
export const devfileSelectorExtensionPlugin = createPlugin({
id: 'devfile-selector-extension'
})
export const DevfileSelectorFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
name: 'DevfileSelectorExtension',
component: DevfileSelectorExtension,
schema: DevfileSelectorExtensionWithOptionsSchema,
}),
);

View File

@ -0,0 +1,5 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'plugin-scaffolder-frontend-module-devfile-field',
});

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -0,0 +1,12 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": [
"src",
"dev"
],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": "."
}
}

View File

@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);

View File

@ -0,0 +1,138 @@
# scaffolder-odo-actions
This is a [Backend Plugin](https://backstage.io/docs/plugins/backend-plugin/) containing a set of [Custom Actions](https://backstage.io/docs/features/software-templates/writing-custom-actions) using the [`odo`](https://odo.dev/) CLI.
It contains the following actions:
- `devfile:odo:command`: a generic action that can execute any `odo` command from the scaffolder workspace.
- `devfile:odo:component:init`: allows to execute the [`odo init`](https://odo.dev/docs/command-reference/init) command from the Scaffolder workspace. The goal of this action is to generate a starter project for a given Devfile that can be customized later on.
## Preview
![Preview](https://github.com/rm3l/backstage-odo-devfile-plugin/assets/593208/ee3192cd-b44b-4d7f-9b27-451574e8cf8c)
## Installation
From your Backstage instance root folder:
```shell
yarn add --cwd packages/backend @backstage-community/plugin-scaffolder-backend-module-odo
```
This will download the right `odo` binary for the current operating system and architecture from the Red Hat servers at <https://developers.redhat.com/content-gateway/rest/mirror/pub/openshift-v4/clients/odo/>.
This behavior can be customized by adding a new `"odo"` field in your `packages/backend/package.json` file, like so:
```json
// packages/backend/package.json
{
"odo": {
// specifying the version is optional.
// You can also specify "latest" to use the latest version of odo, or "nightly" to use the latest nightly build of odo.
"version": "3.15.0",
"skipDownload": false
}
}
```
Note that the custom actions here do require an `odo` binary to work properly.
So if you choose to skip the download (using the `odo.skipDownload` property above), you need to make sure to meet any of the requirements below:
- either you can explicitly set the path to the `odo` binary in your `app-config.yaml` (see [below](#app-configyaml));
- or `odo` is already [installed](https://odo.dev/docs/overview/installation) and available globally in the system paths of the environment the Backstage instance is running in.
## Configuration
### Code
Import the module by modifying the `packages/backend/src/index.ts` file on your Backstage instance:
```ts
// packages/backend/src/index.ts
backend.add(import('@backstage-community/plugin-scaffolder-backend-module-odo'))
```
### app-config.yaml
Optionally, the behavior of these custom actions can be customized by adding the following section to your `app-config.yaml` file:
```yaml
# app-config.yaml
odo:
# When adding this plugin to your Backstage instance, it will automatically try to download the right odo binary and use it.
# But if you already have odo installed, you can override the path below.
# binaryPath: '/path/to/odo'
telemetry:
# Disable the odo telemetry.
# Default: false
disabled: false
devfileRegistry:
# Used for calling `odo init` and any other custom actions relying on a Devfile registry.
# If you are using the Devfile Selector Custom Field Extension in your template,
# you need to also add this URL to the 'proxy.endpoints' field under a '/devfile-registry' field.
# Default: 'https://registry.devfile.io'
url: 'https://registry.devfile.io'
```
You should now see the custom `devfile:odo:*` actions if you navigate to the Actions page at <http://localhost:3000/create/actions>.
![List of Actions](https://github.com/rm3l/backstage-odo-devfile-plugin/assets/593208/850437d3-f5a8-4544-b93e-95e3de218b90)
## Usage
You can use the action in any of the steps of your [Software Template](https://backstage.io/docs/features/software-templates/).
See <https://github.com/ododev/odo-backstage-software-template> for an example of a Software Template making use of the Actions here.
### Example with the `odo init` action
This action can be used in conjunction with the [odo-module-devfile-field-extension](../odo-module-devfile-field-extension) Custom Field Extension to get the Devfile input data from the end-user, e.g.:
```yaml
spec:
parameters:
- title: Provide details about the Devfile
required:
- devfile_data
properties:
devfile_data:
type: object
required:
- devfile
- version
properties:
devfile:
type: string
version:
type: string
starter_project:
type: string
ui:field: DevfileSelectorExtension
steps:
- id: odo-init
name: Generate
action: devfile:odo:component:init
input:
name: ${{ parameters.name }}
devfile: ${{ parameters.devfile_data.devfile }}
version: ${{ parameters.devfile_data.version }}
starter_project: ${{ parameters.devfile_data.starter_project }}
```
### Example with the generic `odo` action
```yaml
spec:
# [...]
steps:
- id: generic-odo-command
name: Execute odo command
action: devfile:odo:command
input:
command: ${{ parameters.command }} # e.g.: 'analyze'
args: ${{ parameters.args }} # e.g.: ['-o', 'json']
```

View File

@ -0,0 +1,25 @@
export interface Config {
odo: {
/**
* Path to the odo binary.
* Note that when installing the custom actions, the latest version of odo is always downloaded.
* But this config option allows to use a different binary if needed.
* @visibility backend
*/
binaryPath: string | undefined;
telemetry: {
/**
* odo telemetry status
* @visibility backend
*/
disabled: boolean | undefined;
},
devfileRegistry: {
/**
* devfile registry URL
* @visibility backend
*/
url: string | undefined;
};
};
}

View File

@ -0,0 +1,64 @@
{
"name": "@backstage-community/plugin-scaffolder-backend-module-odo",
"version": "0.21.0",
"description": "The odo module for @backstage/plugin-scaffolder-backend",
"author": "Red Hat",
"homepage": "https://odo.dev",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "scaffolder",
"pluginPackage": "@backstage/plugin-scaffolder-backend"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/odo/plugins/scaffolder-backend-module-odo"
},
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"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",
"postinstall": "node scripts/post-install.js"
},
"dependencies": {
"@backstage/backend-plugin-api": "^0.6.21",
"@backstage/plugin-scaffolder-node": "^0.4.7",
"cachedir": "^2.4.0",
"fs-extra": "^11.2.0",
"got": "^11.7.0",
"gunzip-maybe": "^1.4.2",
"hasha": "^5.2.2",
"pkg-conf": "^3.1.0",
"tar-fs": "^2.1.0",
"unzip-stream": "^0.3.1"
},
"devDependencies": {
"@backstage/cli": "^0.26.10",
"@backstage/config": "^1.2.0",
"@types/fs-extra": "^11.0.4"
},
"files": [
"scripts",
"dist",
"package.json",
"README.md",
"config.d.ts"
],
"configSchema": "config.d.ts"
}

View File

@ -0,0 +1,224 @@
"use strict";
// Inspired from https://github.com/ipfs/npm-kubo
const goenv = require("./go-platform");
const gunzip = require("gunzip-maybe");
const got = require("got").default;
const path = require("path");
const tarFS = require("tar-fs");
const unzip = require("unzip-stream");
const pkgConf = require("pkg-conf");
const cachedir = require("cachedir");
const fs = require("fs");
const hasha = require("hasha");
// Version of odo to install. This is known to be working with this plugin.
// Can be overridden by clients either via the BACKSTAGE_ODO_PLUGIN__ODO_VERSION environment variable
// or via the 'odo.version' field in their 'package.json' file.
const ODO_VERSION = "3.15.0";
const ODO_DIST_URL =
"https://developers.redhat.com/content-gateway/rest/mirror/pub/openshift-v4/clients/odo";
const ODO_DIST_URL_NIGHTLY = "https://s3.eu-de.cloud-object-storage.appdomain.cloud/odo-nightly-builds";
// Map of all architectures that can be donwloaded on the odo distribution URL.
const SUPPORTED_ARCHITECTURES_BY_PLATFORM = new Map(
Object.entries({
darwin: ["amd64", "arm64"],
linux: ["amd64", "arm64", "ppc64le", "s390x"],
windows: ["amd64"],
})
);
/**
* This avoids an expensive download if file is already in cache.
*
* @param {string} url
* @param {string} platform
* @param {string} arch
* @param {string} version
*/
async function cachingFetchAndVerify(url, platform, arch, version) {
const parentCacheDir = cachedir("odo");
const cacheDir = path.join(parentCacheDir, version);
const filename = url.split("/").pop();
if (!filename) {
throw new Error(`Invalid URL: ${url}`);
}
const cachedFilePath = path.join(cacheDir, filename);
const cachedHashPath = `${cachedFilePath}.sha256`;
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
if (version === "latest" || version === "nightly" || !fs.existsSync(cachedFilePath)) {
console.info(`Downloading ${url} to ${cacheDir}`);
// download file
fs.writeFileSync(cachedFilePath, await got(url).buffer(), {
flag: fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY,
});
console.info(`Downloaded ${url}`);
// ..and checksum
console.info(`Downloading ${filename}.sha256`);
fs.writeFileSync(cachedHashPath, await got(`${url}.sha256`).buffer(), {
flag: fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY,
});
console.info(`Downloaded ${filename}.sha256`);
} else {
console.info(`Found ${cachedFilePath}`);
}
console.info(`Verifying ${filename}.sha256`);
const digest = Buffer.alloc(64);
const fd = fs.openSync(cachedHashPath, "r");
fs.readSync(fd, digest, 0, digest.length, 0);
fs.closeSync(fd);
const expectedSha = digest.toString("utf8");
const calculatedSha = await hasha.fromFile(cachedFilePath, {
encoding: "hex",
algorithm: "sha256",
});
if (calculatedSha !== expectedSha) {
console.log(`calculatedSha: ${calculatedSha.length}`);
console.log(`expectedSha: ${expectedSha.length}`);
throw new Error(
`sha256 mismatch for file ${cachedFilePath}. Expected '${expectedSha}', but calculated '${calculatedSha}'.
Maybe the file downloaded was incomplete? Try to delete the file to force a re-download: ${cachedFilePath}`
);
}
console.log(`OK (${expectedSha})`);
const data = fs.createReadStream(cachedFilePath);
await unpack(url, parentCacheDir, data);
console.info(`Unpacked into ${parentCacheDir}`);
// Rename file if needed
let resultingFileName = "odo";
switch (platform) {
case "windows":
resultingFileName = "odo.exe";
fs.renameSync(path.join(parentCacheDir, `odo-${platform}-${arch}.exe`), path.join(parentCacheDir, resultingFileName));
break;
case "darwin":
fs.renameSync(path.join(parentCacheDir, `odo-${platform}-${arch}`), path.join(parentCacheDir, resultingFileName));
break;
case "linux":
if (fs.existsSync(path.join(parentCacheDir, `odo-${platform}-${arch}`))) {
fs.renameSync(path.join(parentCacheDir, `odo-${platform}-${arch}`), path.join(parentCacheDir, resultingFileName));
}
break;
default:
break;
}
const p = path.join(parentCacheDir, resultingFileName);
console.log(`odo binary (${version}) available at '${p}'`);
return p;
}
/**
* @param {string} url
* @param {string} installPath
* @param {import('stream').Readable} stream
*/
function unpack(url, installPath, stream) {
return new Promise((resolve, reject) => {
if (url.endsWith(".zip")) {
return stream.pipe(
unzip
.Extract({ path: installPath })
.on("close", resolve)
.on("error", reject)
);
}
return stream
.pipe(gunzip())
.pipe(
tarFS.extract(installPath).on("finish", resolve).on("error", reject)
);
});
}
/**
* @param {object} options
* @param {string} options.version
* @param {string} options.platform
* @param {string} options.arch
*/
async function download({ version, platform, arch }) {
let versionToDl = version;
if (versionToDl !== "latest" && versionToDl !== "nightly" && !versionToDl.startsWith("v")) {
versionToDl = `v${version}`;
}
let url = `${ODO_DIST_URL}/${versionToDl}`;
if (versionToDl === "nightly") {
url = ODO_DIST_URL_NIGHTLY;
}
url += `/odo-${platform}-${arch}.${platform === "windows" ? "exe.zip" : "tar.gz"}`;
return await cachingFetchAndVerify(url, platform, arch, version);
}
/**
* @param {string} [platform]
* @param {string} [arch]
*/
function enforcePlatformAndArch(platform, arch) {
if (!SUPPORTED_ARCHITECTURES_BY_PLATFORM.has(platform)) {
throw new Error(
`No binary available for platform: ${platform}. Supported platforms: ${Array.from(
SUPPORTED_ARCHITECTURES_BY_PLATFORM.keys()
).join(", ")}`
);
}
const archs = SUPPORTED_ARCHITECTURES_BY_PLATFORM.get(platform);
if (!archs?.includes(arch)) {
throw new Error(
`No binary available for platform/arch: ${platform}/${arch}. Supported architectures for ${platform}: ${archs.join(
", "
)}`
);
}
}
/**
* @param {string} [version]
* @param {string} [platform]
* @param {string} [arch]
*/
function buildArguments(version, platform, arch) {
const conf = pkgConf.sync("odo", {
cwd: process.cwd(),
defaults: {
version: ODO_VERSION,
distUrl: ODO_DIST_URL,
},
});
return {
version:
process.env.BACKSTAGE_ODO_PLUGIN__ODO_VERSION || version || conf.version,
platform:
process.env.BACKSTAGE_ODO_PLUGIN__TARGET_OS || platform || goenv.GOOS,
arch: process.env.BACKSTAGE_ODO_PLUGIN__TARGET_ARCH || arch || goenv.GOARCH,
};
}
/**
* @param {string} [version]
* @param {string} [platform]
* @param {string} [arch]
*/
module.exports = async (version, platform, arch) => {
const args = buildArguments(version, platform, arch);
enforcePlatformAndArch(args.platform, args.arch);
return await download(args);
};

View File

@ -0,0 +1,25 @@
"use strict";
function getGoOs() {
if (process.platform === "win32") {
return "windows";
}
return process.platform;
}
function getGoArch() {
switch (process.arch) {
case "ia32":
return "386";
case "x64":
return "amd64";
default:
return process.arch;
}
}
module.exports = {
GOOS: getGoOs(),
GOARCH: getGoArch(),
};

View File

@ -0,0 +1,18 @@
"use strict";
const conf = require("pkg-conf").sync("odo", {
cwd: process.cwd(),
defaults: {
skipDownload: false,
},
});
if (conf.skipDownload) {
console.info("Skipping download of odo as requested in package.json");
} else {
const download = require("./download");
download().catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@ -0,0 +1,2 @@
export * from './odo';
export * from './odo-init';

View File

@ -0,0 +1,150 @@
import type { Config } from '@backstage/config';
import {
createTemplateAction,
executeShellCommand,
} from '@backstage/plugin-scaffolder-node';
import cachedir from 'cachedir';
import fs from 'fs-extra';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export const createOdoInitAction = ({
odoConfig,
}: {
odoConfig: Config | undefined;
}) => {
return createTemplateAction<{
devfile: string;
version: string;
starter_project: string | undefined;
name: string;
}>({
id: 'devfile:odo:component:init',
schema: {
input: {
required: ['devfile', 'version', 'name'],
type: 'object',
properties: {
devfile: {
type: 'string',
title: 'Devfile',
description: 'The Devfile',
},
version: {
type: 'string',
title: 'Version',
description: 'The Devfile Stack version',
},
starter_project: {
type: 'string',
title: 'Starter Project',
description: 'The starter project',
},
name: {
type: 'string',
title: 'Component name',
description: 'The new component name',
},
},
},
},
async handler(ctx) {
ctx.logger.info(`Workspace: "${ctx.workspacePath}"`);
ctx.logger.info(
`Init "${ctx.input.name}" from: devfile=${ctx.input.devfile}, version=${ctx.input.version}, starterProject=${ctx.input.starter_project}...`,
);
const telemetryDisabled =
odoConfig?.getOptionalBoolean('telemetry.disabled') ?? false;
ctx.logger.info(`...telemetry disabled: ${telemetryDisabled}`);
// Create a temporary file to use as dedicated config for odo
const tmpDir = await fs.mkdtemp(join(tmpdir(), 'odo-init-'));
const odoConfigFilePath = join(tmpDir, 'config');
ctx.logger.info(`...temp dir for odo config: ${tmpDir}`);
const envVars = {
// Due to a limitation in Node's child_process, the command lookup will be performed using options.env.PATH if options.env is defined.
// See https://nodejs.org/docs/latest-v18.x/api/child_process.html#child_process_child_process
...process.env,
GLOBALODOCONFIG: odoConfigFilePath,
ODO_TRACKING_CONSENT: telemetryDisabled ? 'no' : 'yes',
TELEMETRY_CALLER: 'backstage',
};
const randomRegistryName = 'GeneratedRegistryName';
const devfileRegistryUrl =
odoConfig?.getOptionalString('devfileRegistry.url') ??
'https://registry.devfile.io';
ctx.logger.info(`...devfile registry URL: ${devfileRegistryUrl}`);
await fs.createFile(odoConfigFilePath);
let odoBinaryPath = odoConfig?.getOptionalString('binaryPath');
if (!odoBinaryPath) {
// Resolve from the downloaded dir
odoBinaryPath = join(
cachedir('odo'),
`odo${process.platform === 'win32' ? '.exe' : ''}`,
);
if (!fs.existsSync(odoBinaryPath)) {
// Fallback to any odo command available in the PATH
ctx.logger.info(
`odo binary path not set in app-config.yaml and not found in auto-download path (${odoBinaryPath}) => falling back to "odo" in the system PATH`,
);
odoBinaryPath = 'odo';
}
}
ctx.logger.info(`odo binary path: ${odoBinaryPath}`);
// Add registry
await executeShellCommand({
command: odoBinaryPath,
args: [
'preference',
'add',
'registry',
randomRegistryName,
devfileRegistryUrl,
],
logStream: ctx.logStream,
options: {
cwd: ctx.workspacePath,
env: envVars,
},
});
// odo init
const initArgs = [
'init',
'--name',
ctx.input.name,
'--devfile-registry',
randomRegistryName,
'--devfile',
ctx.input.devfile,
'--devfile-version',
ctx.input.version,
];
if (ctx.input.starter_project) {
initArgs.push('--starter', ctx.input.starter_project);
}
await executeShellCommand({
command: odoBinaryPath,
args: initArgs,
logStream: ctx.logStream,
options: {
cwd: ctx.workspacePath,
env: envVars,
},
});
fs.rm(tmpDir, { recursive: true, maxRetries: 2, force: true }, () => {});
ctx.logger.info(
`...Finished creating "${ctx.input.name}" from: devfile=${ctx.input.devfile}, version=${ctx.input.version}, starterProject=${ctx.input.starter_project}`,
);
},
});
};

View File

@ -0,0 +1,101 @@
import type { Config } from '@backstage/config';
import {
createTemplateAction,
executeShellCommand,
} from '@backstage/plugin-scaffolder-node';
import cachedir from 'cachedir';
import fs from 'fs-extra';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export const createOdoAction = ({
odoConfig,
}: {
odoConfig: Config | undefined;
}) => {
return createTemplateAction<{
workingDirectory: string;
command: string;
args: string[];
}>({
id: 'devfile:odo:command',
schema: {
input: {
required: ['command'],
type: 'object',
properties: {
command: {
type: 'string',
title: 'Command',
description: 'The odo command to run from the scaffolder workspace',
},
args: {
type: 'array',
items: {
type: 'string',
},
title: 'Arguments',
description: 'Arguments to pass to the command',
},
},
},
},
async handler(ctx: any) {
let args = [ctx.input.command];
if (ctx.input.args?.length) {
args = [...args, ...ctx.input.args];
}
ctx.logger.info(`Workspace: "${ctx.workspacePath}"`);
ctx.logger.info(`Running ${args}...`);
const telemetryDisabled =
odoConfig?.getOptionalBoolean('telemetry.disabled') ?? false;
ctx.logger.info(`...telemetry disabled: ${telemetryDisabled}`);
// Create a temporary file to use as dedicated config for odo
const tmpDir = await fs.mkdtemp(join(tmpdir(), 'odo-'));
const odoConfigFilePath = join(tmpDir, 'config');
const envVars = {
// Due to a limitation in Node's child_process, the command lookup will be performed using options.env.PATH if options.env is defined.
// See https://nodejs.org/docs/latest-v18.x/api/child_process.html#child_process_child_process
...process.env,
GLOBALODOCONFIG: odoConfigFilePath,
ODO_TRACKING_CONSENT: telemetryDisabled ? 'no' : 'yes',
TELEMETRY_CALLER: 'backstage',
};
let odoBinaryPath = odoConfig?.getOptionalString('binaryPath');
if (!odoBinaryPath) {
// Resolve from the downloaded dir
odoBinaryPath = join(
cachedir('odo'),
`odo${process.platform === 'win32' ? '.exe' : ''}`,
);
if (!fs.existsSync(odoBinaryPath)) {
// Fallback to any odo command available in the PATH
ctx.logger.info(
`odo binary path not set in app-config.yaml and not found in auto-download path (${odoBinaryPath}) => falling back to "odo" in the system PATH`,
);
odoBinaryPath = 'odo';
}
}
ctx.logger.info(`odo binary path: ${odoBinaryPath}`);
await fs.createFile(odoConfigFilePath);
await executeShellCommand({
command: odoBinaryPath,
args: args,
logStream: ctx.logStream,
options: {
cwd: ctx.workspacePath,
env: envVars,
},
});
fs.rm(tmpDir, { recursive: true, maxRetries: 2, force: true }, () => {});
ctx.logger.info(`Finished executing odo ${ctx.input.command}`);
},
});
};

View File

@ -0,0 +1,2 @@
export * from './actions';
export { odoModule as default } from './module';

View File

@ -0,0 +1,31 @@
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import { createOdoAction, createOdoInitAction } from './actions';
/**
* @public
* The Odo Module for the Scaffolder Backend
*/
export const odoModule = createBackendModule({
pluginId: 'scaffolder',
moduleId: 'github',
register({ registerInit }) {
registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
config: coreServices.rootConfig,
},
async init({ scaffolder, config }) {
const odoConfig = config.getOptionalConfig('odo');
scaffolder.addActions(
createOdoAction({ odoConfig }),
createOdoInitAction({ odoConfig }),
);
},
});
},
});

View File

@ -0,0 +1,18 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": [
"packages/*/src",
"plugins/*/src",
"plugins/*/dev",
"plugins/*/migrations"
],
"files": ["node_modules/@backstage/cli/asset-types/asset-types.d.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": ".",
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2022"],
"target": "ES2022",
"useUnknownInCatchVariables": false
}
}

26470
workspaces/odo/yarn.lock Normal file

File diff suppressed because it is too large Load Diff