diff --git a/build/dockerfiles/Dockerfile b/build/dockerfiles/Dockerfile index 10db4c8c0..290e94177 100644 --- a/build/dockerfiles/Dockerfile +++ b/build/dockerfiles/Dockerfile @@ -11,7 +11,8 @@ FROM docker.io/node:18.19.1-alpine3.19 as builder # hadolint ignore=DL3018 -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache python3 py3-pip make g++ jq curl +RUN pip3 install yq --break-system-packages # hadolint ignore=DL3059,SC1072 RUN if ! [ type "yarn" &> /dev/null ]; then \ @@ -40,6 +41,11 @@ RUN yarn build # leave only production dependencies RUN yarn workspace @eclipse-che/dashboard-backend install --production +# Prepare air-gapped resources +# ARG GITHUB_TOKEN=$GITHUB_TOKEN +COPY build/dockerfiles/airgap.sh /dashboard/airgap.sh +RUN /dashboard/airgap.sh -d /dashboard/packages/devfile-registry/air-gap + FROM docker.io/node:18.19.1-alpine3.19 RUN apk --no-cache add curl diff --git a/build/dockerfiles/airgap.sh b/build/dockerfiles/airgap.sh new file mode 100755 index 000000000..da3af1e19 --- /dev/null +++ b/build/dockerfiles/airgap.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# +# Copyright (c) 2021-2024 Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# + +# The script is used to download resources (projects and devfiles) +# for air-gapped (offline) environments. Only https://github.com is supported for now. + +set -e + +init() { + unset AIRGAP_RESOURCES_DIR + + while [ "$#" -gt 0 ]; do + case $1 in + '--airgap-resources-dir'|'-d') AIRGAP_RESOURCES_DIR=$2; shift 1;; + '--help'|'-h') usage; exit;; + esac + shift 1 + done + + [ -z "${AIRGAP_RESOURCES_DIR}" ] && { usage; exit; } + SAMPLES_JSON_PATH="${AIRGAP_RESOURCES_DIR}/index.json" +} + +usage() { + cat <; } + +export interface IAirGapSample extends IGettingStartedSample { + project?: { zip?: { filename?: string } }; + devfile?: { filename?: string }; +} + +export interface IStreamedFile { + stream: ReadStream; + size: number; +} diff --git a/packages/dashboard-backend/package.json b/packages/dashboard-backend/package.json index a8dbf200e..50ec06135 100644 --- a/packages/dashboard-backend/package.json +++ b/packages/dashboard-backend/package.json @@ -37,6 +37,7 @@ "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "3.0.0", "@fastify/websocket": "^10.0.1", + "@fastify/rate-limit": "^9.1.0", "@kubernetes/client-node": "^0.21.0", "args": "^5.0.3", "axios": "^1.7.0", diff --git a/packages/dashboard-backend/src/app.ts b/packages/dashboard-backend/src/app.ts index 83bc5459e..31e49d2de 100644 --- a/packages/dashboard-backend/src/app.ts +++ b/packages/dashboard-backend/src/app.ts @@ -21,6 +21,7 @@ import { registerCors } from '@/plugins/cors'; import { registerStaticServer } from '@/plugins/staticServer'; import { registerSwagger } from '@/plugins/swagger'; import { registerWebSocket } from '@/plugins/webSocket'; +import { registerAirGapSampleRoute } from '@/routes/api/airGapSample'; import { registerClusterConfigRoute } from '@/routes/api/clusterConfig'; import { registerClusterInfoRoute } from '@/routes/api/clusterInfo'; import { registerDataResolverRoute } from '@/routes/api/dataResolver'; @@ -68,6 +69,8 @@ export default async function buildApp(server: FastifyInstance): Promise { + let airGapSampleApiService: AirGapSampleApiService; + + beforeEach(() => { + jest.resetModules(); + airGapSampleApiService = new AirGapSampleApiService( + path.join(__dirname, 'fixtures', 'air-gap'), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('fetching metadata', async () => { + const res = await airGapSampleApiService.list(); + expect(res.length).toEqual(6); + }); + + test('reading devfile', async () => { + const airGapResource = await airGapSampleApiService.downloadDevfile('Sample_devfile'); + const devfileContent = await streamToString(airGapResource.stream); + expect(devfileContent.length).toEqual(airGapResource.size); + expect(devfileContent).toEqual( + 'schemaVersion: 2.2.0\n' + 'metadata:\n' + ' generateName: empty\n', + ); + }); + + test('reading project', async () => { + const airGapResource = await airGapSampleApiService.downloadProject('Sample_project'); + const devfileContent = await streamToString(airGapResource.stream); + expect(devfileContent.length).toEqual(airGapResource.size); + expect(devfileContent).toEqual('project'); + }); + + test('error reading devfile, if field not specified', async () => { + try { + await airGapSampleApiService.downloadDevfile('Sample_no_devfile_filename'); + fail('should throw an error'); + } catch (e: any) { + expect(e.message).toEqual('filename not defined'); + } + }); + + test('error reading project, if field not specified', async () => { + try { + await airGapSampleApiService.downloadProject('Sample_no_project_filename'); + fail('should throw an error'); + } catch (e: any) { + expect(e.message).toEqual('filename not defined'); + } + }); + + test('error reading devfile, if devfile does not exist', async () => { + try { + await airGapSampleApiService.downloadDevfile('Sample_devfile_not_exists'); + fail('should throw an error'); + } catch (e: any) { + expect(e.message).toEqual('File not found'); + } + }); + + test('error reading project, if project does not exist', async () => { + try { + await airGapSampleApiService.downloadProject('Sample_project_not_exists'); + fail('should throw an error'); + } catch (e: any) { + expect(e.message).toEqual('File not found'); + } + }); + + test('error reading devfile, sample not found', async () => { + try { + await airGapSampleApiService.downloadDevfile('Sample_not_exists'); + fail('should throw an error'); + } catch (e: any) { + expect(e.message).toEqual('Sample not found'); + } + }); + + test('error reading project, sample not found', async () => { + try { + await airGapSampleApiService.downloadProject('Sample_not_exists'); + fail('should throw an error'); + } catch (e: any) { + expect(e.message).toEqual('Sample not found'); + } + }); +}); + +function streamToString(stream: NodeJS.ReadableStream): Promise { + const chunks: any[] = []; + return new Promise((resolve, reject) => { + stream.on('data', chunk => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString())); + }); +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/index.json b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/index.json new file mode 100644 index 000000000..ac083f9b7 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/index.json @@ -0,0 +1,36 @@ +[ + { + "displayName": "Sample_no_devfile_filename" + }, + { + "displayName": "Sample_no_project_filename" + }, + { + "displayName": "Sample_devfile_not_exists", + "devfile": { + "filename": "not-exists-defile.yaml" + } + }, + { + "displayName": "Sample_project_not_exists", + "project": { + "zip": { + "filename": "not-exists-project.yaml" + } + } + }, + { + "displayName": "Sample_devfile", + "devfile": { + "filename": "sample-devfile.yaml" + } + }, + { + "displayName": "Sample_project", + "project": { + "zip": { + "filename": "sample-project.zip" + } + } + } +] diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/sample-devfile.yaml b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/sample-devfile.yaml new file mode 100644 index 000000000..d3f8a8170 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/sample-devfile.yaml @@ -0,0 +1,3 @@ +schemaVersion: 2.2.0 +metadata: + generateName: empty diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/sample-project.zip b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/sample-project.zip new file mode 100644 index 000000000..e3f094ee6 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/fixtures/air-gap/sample-project.zip @@ -0,0 +1 @@ +project \ No newline at end of file diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/airGapSampleApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/airGapSampleApi.ts new file mode 100644 index 000000000..74dc72c97 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/airGapSampleApi.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; +import * as console from 'console'; +import * as fs from 'fs'; +import path from 'path'; + +import { IAirGapSampleApi } from '@/devworkspaceClient/types'; +import { isLocalRun } from '@/localRun'; + +export class AirGapSampleApiService implements IAirGapSampleApi { + protected readonly airGapResourcesDir: string; + protected readonly airGapIndexFilePath: string; + protected readonly samples: Array; + constructor(airGapResourcesDir?: string) { + this.airGapResourcesDir = airGapResourcesDir + ? airGapResourcesDir + : isLocalRun() + ? path.join( + __dirname, + '../../../dashboard-frontend/lib/public/dashboard/devfile-registry/air-gap', + ) + : '/public/dashboard/devfile-registry/air-gap'; + this.airGapIndexFilePath = path.join(this.airGapResourcesDir, 'index.json'); + this.samples = this.readAirGapIndex(); + } + + async list(): Promise> { + return this.samples; + } + + async downloadProject(name: string): Promise { + const sample = this.samples.find(sample => sample.displayName === name); + if (sample) { + return this.download(sample.project?.zip?.filename); + } + + console.error(`Sample not found: ${name} `); + throw new Error(`Sample not found`); + } + + async downloadDevfile(name: string): Promise { + const sample = this.samples.find(sample => sample.displayName === name); + if (sample) { + return this.download(sample.devfile?.filename); + } + + console.error(`Sample not found: ${name} `); + throw new Error(`Sample not found`); + } + + private readAirGapIndex(): Array { + if (!fs.existsSync(this.airGapIndexFilePath)) { + return []; + } + + try { + const data = fs.readFileSync(this.airGapIndexFilePath, 'utf8'); + return JSON.parse(data) as api.IAirGapSample[]; + } catch (e) { + console.error(e, 'Failed to read air-gap index.json'); + throw new Error('Failed to read air-gap index.json'); + } + } + + private download(filename?: string): api.IStreamedFile { + if (!filename) { + console.error(`filename not defined`); + throw new Error(`filename not defined`); + } + + const filepath = path.resolve(this.airGapResourcesDir, filename); + + // This is a security check to ensure that the file is within the airGapResourcesDir + if (!filepath.startsWith(this.airGapResourcesDir)) { + console.error(`Invalid file path: ${filepath}`); + throw new Error(`Invalid file path`); + } + + if (!fs.existsSync(filepath)) { + console.error(`File not found: ${filepath}`); + throw new Error(`File not found`); + } + + try { + const stats = fs.statSync(filepath); + return { stream: fs.createReadStream(filepath), size: stats.size }; + } catch (err) { + console.error(`Error reading file: ${filepath}`, err); + throw new Error(`Error reading file`); + } + } +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index fcdc51b2a..2c92dbc09 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -426,6 +426,7 @@ export interface IDevWorkspaceClient { userProfileApi: IUserProfileApi; gitConfigApi: IGitConfigApi; gettingStartedSampleApi: IGettingStartedSampleApi; + airGapSampleApi: IAirGapSampleApi; sshKeysApi: IShhKeysApi; devWorkspacePreferencesApi: IDevWorkspacePreferencesApi; editorsApi: IEditorsApi; @@ -452,6 +453,23 @@ export interface IGettingStartedSampleApi { list(): Promise>; } +export interface IAirGapSampleApi { + /** + * Reads all the Air Gap samples. + */ + list(): Promise>; + + /** + * Downloads the Air Gap sample project by its name. + */ + downloadProject(name: string): Promise; + + /** + * Reads the devfile content of the Air Gap sample by its name. + */ + downloadDevfile(name: string): Promise; +} + export interface IEditorsApi { /** * Reads all Editors from ConfigMaps. diff --git a/packages/dashboard-backend/src/routes/api/__tests__/airGapSample.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/airGapSample.spec.ts new file mode 100644 index 000000000..0cc8df794 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/__tests__/airGapSample.spec.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance } from 'fastify'; +import * as stream from 'stream'; + +import { baseApiPath } from '@/constants/config'; +import { DevWorkspaceClient } from '@/devworkspaceClient'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; +import { setup, teardown } from '@/utils/appBuilder'; + +jest.mock('@/routes/api/helpers/getServiceAccountToken'); +jest.mock('@/routes/api/helpers/getDevWorkspaceClient'); + +describe('AirGap Sample Route', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await setup(); + }); + + afterAll(() => { + teardown(app); + jest.clearAllMocks(); + }); + + test('GET ${baseApiPath}/airgap-sample', async () => { + (getDevWorkspaceClient as jest.Mock).mockImplementation(() => { + return { + airGapSampleApi: { + list: () => Promise.resolve('[]'), + }, + } as unknown as DevWorkspaceClient; + }); + + const res = await app.inject().get(`${baseApiPath}/airgap-sample`); + + expect(res.statusCode).toEqual(200); + expect(res.json()).toEqual([]); + }); + + test('GET ${baseApiPath}/devfile/download', async () => { + (getDevWorkspaceClient as jest.Mock).mockImplementation(() => { + return { + airGapSampleApi: { + downloadDevfile: () => + Promise.resolve({ + stream: new stream.Readable({ + read() { + this.push('devfile'); + this.push(null); + }, + }), + size: 6, + }), + }, + } as unknown as DevWorkspaceClient; + }); + + const res = await app.inject().get(`${baseApiPath}/airgap-sample/devfile/download?name=sample`); + + expect(res.statusCode).toEqual(200); + expect(res.headers['content-type']).toEqual('application/octet-stream'); + expect(res.headers['content-length']).toEqual('6'); + expect(res.body).toEqual('devfile'); + }); + + test('GET ${baseApiPath}/project/download', async () => { + (getDevWorkspaceClient as jest.Mock).mockImplementation(() => { + return { + airGapSampleApi: { + downloadProject: () => + Promise.resolve({ + stream: new stream.Readable({ + read() { + this.push('project'); + this.push(null); + }, + }), + size: 7, + }), + }, + } as unknown as DevWorkspaceClient; + }); + + const res = await app.inject().get(`${baseApiPath}/airgap-sample/project/download?name=sample`); + + expect(res.statusCode).toEqual(200); + expect(res.headers['content-type']).toEqual('application/octet-stream'); + expect(res.headers['content-length']).toEqual('7'); + expect(res.body).toEqual('project'); + }); +}); diff --git a/packages/dashboard-backend/src/routes/api/airGapSample.ts b/packages/dashboard-backend/src/routes/api/airGapSample.ts new file mode 100644 index 000000000..f95401d5e --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/airGapSample.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; +import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; +import { getSchema } from '@/services/helpers'; + +const tags = ['Air Gapped sample']; +const rateLimitConfig = { + config: { + rateLimit: { + max: 30, + timeWindow: '1 minute', + }, + }, +}; + +export function registerAirGapSampleRoute(instance: FastifyInstance) { + instance.register(async server => { + server.get( + `${baseApiPath}/airgap-sample`, + Object.assign(rateLimitConfig, getSchema({ tags })), + async () => { + const token = getServiceAccountToken(); + const { airGapSampleApi } = getDevWorkspaceClient(token); + return airGapSampleApi.list(); + }, + ); + + server.get( + `${baseApiPath}/airgap-sample/devfile/download`, + Object.assign(rateLimitConfig, getSchema({ tags })), + async function (request: FastifyRequest, reply: FastifyReply) { + const name = (request.query as { name: string })['name']; + if (!name) { + return reply.status(400).send('Sample name is required.'); + } + + const token = getServiceAccountToken(); + const { airGapSampleApi } = getDevWorkspaceClient(token); + + try { + const iStreamedFile = await airGapSampleApi.downloadDevfile(name); + reply.header('Content-Type', 'application/octet-stream'); + reply.header('Content-Length', iStreamedFile.size); + return reply.send(iStreamedFile.stream); + } catch (err: any) { + console.error(`Error downloading file`, err); + return reply.status(500).send(`Error downloading file`); + } + }, + ); + + server.get( + `${baseApiPath}/airgap-sample/project/download`, + Object.assign(rateLimitConfig, getSchema({ tags })), + async function (request: FastifyRequest, reply: FastifyReply) { + const name = (request.query as { name: string })['name']; + if (!name) { + return reply.status(400).send('Sample name is required.'); + } + + const token = getServiceAccountToken(); + const { airGapSampleApi } = getDevWorkspaceClient(token); + + try { + const iStreamedFile = await airGapSampleApi.downloadProject(name); + reply.header('Content-Type', 'application/octet-stream'); + reply.header('Content-Length', iStreamedFile.size); + return reply.send(iStreamedFile.stream); + } catch (err: any) { + console.error(`Error downloading file`, err); + return reply.status(500).send(`Error downloading file`); + } + }, + ); + }); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx index 13d5410cd..f57abb608 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx @@ -33,7 +33,7 @@ export type PluginEditor = che.Plugin & { isDefault: boolean; }; -export const VISIBLE_TAGS = ['Community', 'Tech-Preview', 'Devfile.io']; +export const VISIBLE_TAGS = ['Community', 'Tech-Preview', 'Devfile.io', 'AirGap']; export type Props = MappedProps & { metadataFiltered: DevfileRegistryMetadata[]; diff --git a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx index 974eab0b5..45fee2df9 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx @@ -81,7 +81,7 @@ describe('Dashboard bootstrap', () => { undefined, ); // wait for all GET requests to be sent - await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(14)); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(15)); await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/dashboard/api/namespace/test-che/ssh-key', undefined), diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index a6a9bedec..b7e7a99a2 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -336,10 +336,19 @@ export default class Bootstrap { const internalDevfileRegistryUrl = serverConfig.devfileRegistryURL; if ( devfileRegistry?.disableInternalRegistry !== undefined && - devfileRegistry?.disableInternalRegistry !== true && - internalDevfileRegistryUrl + devfileRegistry?.disableInternalRegistry !== true ) { - await requestRegistriesMetadata(internalDevfileRegistryUrl, false)( + if (internalDevfileRegistryUrl) { + await requestRegistriesMetadata(internalDevfileRegistryUrl, false)( + this.store.dispatch, + this.store.getState, + undefined, + ); + } + + const airGapedSampleURL = new URL('/dashboard/api/airgap-sample', window.location.origin) + .href; + await requestRegistriesMetadata(airGapedSampleURL, false)( this.store.dispatch, this.store.getState, undefined, diff --git a/packages/dashboard-frontend/src/services/factory-location-adapter/__tests__/factoryLocationAdapter.spec.ts b/packages/dashboard-frontend/src/services/factory-location-adapter/__tests__/factoryLocationAdapter.spec.ts index cf5d3d2a2..b23460fd8 100644 --- a/packages/dashboard-frontend/src/services/factory-location-adapter/__tests__/factoryLocationAdapter.spec.ts +++ b/packages/dashboard-frontend/src/services/factory-location-adapter/__tests__/factoryLocationAdapter.spec.ts @@ -146,6 +146,12 @@ describe('FactoryLocationAdapter Service', () => { const location = 'https://git-test.com/dum my.git'; expect(FactoryLocationAdapter.isHttpLocation(location)).toBeTruthy(); }); + it('should return true for http internal url', () => { + const location = + 'http://che-dashboard.eclipse-che.svc:8080/dashboard/api/airgap-sample/project/download?name=JBoss+EAP+8'; + expect(FactoryLocationAdapter.isHttpLocation(location)).toBeTruthy(); + expect(FactoryLocationAdapter.isSshLocation(location)).toBeFalsy(); + }); }); it('should return factory reference without oauth params', () => { diff --git a/packages/dashboard-frontend/src/services/factory-location-adapter/index.ts b/packages/dashboard-frontend/src/services/factory-location-adapter/index.ts index eae691fbd..dd9e790bf 100644 --- a/packages/dashboard-frontend/src/services/factory-location-adapter/index.ts +++ b/packages/dashboard-frontend/src/services/factory-location-adapter/index.ts @@ -54,7 +54,9 @@ export class FactoryLocationAdapter implements FactoryLocation { } public static isHttpLocation(href: string): boolean { - return /^(https?:\/\/.)[-a-zA-Z0-9@:%._+~#=]{2,}\b([-a-zA-Z0-9@:%_+.~#?&/={}, ]*)$/.test(href); + return /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._+~#=]{2,}\b([-a-zA-Z0-9@:%_+.~#?&/={}, ]*)$/.test( + href, + ); } public static isSshLocation(href: string): boolean { diff --git a/packages/dashboard-frontend/src/services/registry/devfiles.ts b/packages/dashboard-frontend/src/services/registry/devfiles.ts index 1c54fcefc..739c9fd06 100644 --- a/packages/dashboard-frontend/src/services/registry/devfiles.ts +++ b/packages/dashboard-frontend/src/services/registry/devfiles.ts @@ -128,7 +128,10 @@ export function getRegistryIndexLocations(registryUrl: string, isExternal: boole const deprecatedIndexUrl = new URL('devfiles/index.json', registryUrl); registryIndexLocations.push(deprecatedIndexUrl.href); } else { - if (registryUrl.endsWith('/getting-started-sample/')) { + if ( + registryUrl.endsWith('/getting-started-sample/') || + registryUrl.endsWith('/airgap-sample/') + ) { const indexUrl = new URL(registryUrl.slice(0, -1)); registryIndexLocations.push(indexUrl.href); } else { diff --git a/packages/devfile-registry/air-gap/index.json b/packages/devfile-registry/air-gap/index.json new file mode 100644 index 000000000..0d4f101c7 --- /dev/null +++ b/packages/devfile-registry/air-gap/index.json @@ -0,0 +1,2 @@ +[ +] diff --git a/packages/devfile-registry/devfiles/index.json b/packages/devfile-registry/devfiles/index.json index e6d149b0f..1de93708a 100644 --- a/packages/devfile-registry/devfiles/index.json +++ b/packages/devfile-registry/devfiles/index.json @@ -1,11 +1,13 @@ -[{ - "displayName": "Empty Workspace", - "description": "Start an empty remote development environment and create files or clone a git repository afterwards", - "tags": [ - "Empty" - ], - "icon": "/images/empty.svg", - "links": { - "v2": "/devfiles/empty.yaml" +[ + { + "displayName": "Empty Workspace", + "description": "Start an empty remote development environment and create files or clone a git repository afterwards", + "tags": [ + "Empty" + ], + "icon": "/images/empty.svg", + "links": { + "v2": "/devfiles/empty.yaml" + } } -}] +] diff --git a/run/local-run.sh b/run/local-run.sh index 2553e29b9..b82df072e 100755 --- a/run/local-run.sh +++ b/run/local-run.sh @@ -51,6 +51,14 @@ parse_args() { done } +SCRIPT_DIR=$(dirname "$0") + +if [[ "$(uname)" == "Linux" ]]; then + YQ_FLAGS="-r" +else + YQ_FLAGS="e" +fi + FORCE_BUILD="false" CHE_IN_CHE="false" # Init Che Namespace with the default value if it's not set @@ -89,6 +97,12 @@ fi if [ ! -d $DASHBOARD_FRONTEND/lib/public/dashboard/devfile-registry ]; then echo "[INFO] Copy devfile registry" cp -r $DEVFILE_REGISTRY $DASHBOARD_FRONTEND/lib/public/dashboard/devfile-registry + + echo "[INFO] Downloading airgap projects" + . "${SCRIPT_DIR}/../build/dockerfiles/airgap.sh" \ + -d "$DASHBOARD_FRONTEND/lib/public/dashboard/devfile-registry/air-gap" + + sed -i 's|CHE_DASHBOARD_INTERNAL_URL|http://localhost:8080|g' "$DASHBOARD_FRONTEND/lib/public/dashboard/devfile-registry/devfiles/airgap.json" fi export CLUSTER_ACCESS_TOKEN=$(oc whoami -t) @@ -99,8 +113,8 @@ if [[ -z "$CLUSTER_ACCESS_TOKEN" ]]; then echo 'Evaluated Dex ingress' echo 'Looking for staticClientID and staticClientSecret...' - export CLIENT_ID=$(kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq e ".staticClients[0].id" -) - export CLIENT_SECRET=$(kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq e ".staticClients[0].secret" -) + export CLIENT_ID=$(kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq ${YQ_FLAGS} ".staticClients[0].id" -) + export CLIENT_SECRET=$(kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq ${YQ_FLAGS} ".staticClients[0].secret" -) echo 'Done.' fi fi diff --git a/run/prepare-local-run.sh b/run/prepare-local-run.sh index 6f4d53173..885595cca 100755 --- a/run/prepare-local-run.sh +++ b/run/prepare-local-run.sh @@ -29,6 +29,12 @@ parse_args() { parse_args "$@" +if [[ "$(uname)" == "Linux" ]]; then + YQ_FLAGS="-r" +else + YQ_FLAGS="e" +fi + CHE_NAMESPACE="${CHE_NAMESPACE:-eclipse-che}" CHE_SELF_SIGNED_MOUNT_PATH="${CHE_SELF_SIGNED_MOUNT_PATH:-$PWD/run/public-certs}" @@ -66,11 +72,11 @@ if [ "$GATEWAY" == "true" ]; then echo 'Cluster access token not found.' echo 'Looking for staticClient for local start' - if kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq e ".staticClients[0].redirectURIs" - | grep $CHE_HOST/oauth/callback; then + if kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq ${YQ_FLAGS} ".staticClients[0].redirectURIs" - | grep $CHE_HOST/oauth/callback; then echo 'Found the staticClient for localStart' else echo 'Patching dex config map...' - UPDATED_CONFIG_YAML=$(kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq e ".staticClients[0].redirectURIs[0] = \"$CHE_HOST/oauth/callback\"" -) + UPDATED_CONFIG_YAML=$(kubectl get -n dex configMaps/dex -o jsonpath="{.data['config\.yaml']}" | yq ${YQ_FLAGS} ".staticClients[0].redirectURIs[0] = \"$CHE_HOST/oauth/callback\"" -) dq_mid=\\\" yaml_esc="${UPDATED_CONFIG_YAML//\"/$dq_mid}" kubectl get configMaps/dex -n dex -o json | jq ".data[\"config.yaml\"] |= \"${yaml_esc}\"" | kubectl replace -f - @@ -85,7 +91,7 @@ if [ "$GATEWAY" == "true" ]; then fi echo 'Looking for redirect_url for local start' - if kubectl get configMaps che-gateway-config-oauth-proxy -o jsonpath="{.data}" -n "$CHE_NAMESPACE" | yq e ".[\"oauth-proxy.cfg\"]" - | grep $CHE_HOST/oauth/callback; then + if kubectl get configMaps che-gateway-config-oauth-proxy -o jsonpath="{.data}" -n "$CHE_NAMESPACE" | yq ${YQ_FLAGS} ".[\"oauth-proxy.cfg\"]" - | grep $CHE_HOST/oauth/callback; then echo 'Found the redirect_url for localStart' else if kubectl get deployment/che-operator -n "$CHE_NAMESPACE" -o jsonpath="{.spec.replicas}" | grep 1; then @@ -97,7 +103,7 @@ if [ "$GATEWAY" == "true" ]; then fi echo 'Patching che-gateway-config-oauth-proxy config map...' - CONFIG_YAML=$(kubectl get configMaps che-gateway-config-oauth-proxy -o jsonpath="{.data}" -n "$CHE_NAMESPACE" | yq e ".[\"oauth-proxy.cfg\"]" - | sed "s/${CHE_HOST_ORIGIN//\//\\/}\/oauth\/callback/${CHE_HOST//\//\\/}\/oauth\/callback/g") + CONFIG_YAML=$(kubectl get configMaps che-gateway-config-oauth-proxy -o jsonpath="{.data}" -n "$CHE_NAMESPACE" | yq ${YQ_FLAGS} ".[\"oauth-proxy.cfg\"]" - | sed "s/${CHE_HOST_ORIGIN//\//\\/}\/oauth\/callback/${CHE_HOST//\//\\/}\/oauth\/callback/g") kubectl get configMaps che-gateway-config-oauth-proxy -n "$CHE_NAMESPACE" -o json | jq ".data[\"oauth-proxy.cfg\"] |= \"${CONFIG_YAML//\"/\\\"}\"" | kubectl replace -f - # rollout che-server deployment echo 'Rolling out che-gateway deployment...' diff --git a/yarn.lock b/yarn.lock index 915599f13..072b1d4d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -491,6 +491,15 @@ fastify-plugin "^4.5.1" simple-oauth2 "^5.0.0" +"@fastify/rate-limit@^9.1.0": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@fastify/rate-limit/-/rate-limit-9.1.0.tgz#c70f30e8be904c31986e09f262ba0f5ea1ef64b9" + integrity sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA== + dependencies: + "@lukeed/ms" "^2.0.1" + fastify-plugin "^4.0.0" + toad-cache "^3.3.1" + "@fastify/reply-from@^9.0.0": version "9.6.0" resolved "https://registry.yarnpkg.com/@fastify/reply-from/-/reply-from-9.6.0.tgz#63d45f4cd63f95a012549cc56fcb73ea6432d660" @@ -11321,6 +11330,11 @@ toad-cache@^3.3.0: resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.3.0.tgz#5b7dc67b36bc8b960567eb77bdf9ac6c26f204a1" integrity sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg== +toad-cache@^3.3.1: + version "3.7.0" + resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.0.tgz#b9b63304ea7c45ec34d91f1d2fa513517025c441" + integrity sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== + toggle-selection@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"