diff --git a/.github/workflows/build-push-template-image.yaml b/.github/workflows/build-push-template-image.yaml new file mode 100644 index 0000000000..1dcec0728e --- /dev/null +++ b/.github/workflows/build-push-template-image.yaml @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Build Template Plugin 🔧 + +on: + workflow_dispatch: {} + pull_request: + paths: + - apps/template/** + - .github/workflows/build-push-template-image.yaml + +env: + REGISTRY: ghcr.io + IMAGE_NAME: "juno-plugin-template" + PACKAGE_PATH: "apps/template" + DESCRIPTION: "Juno Template Plugin - External plugin PoC for Greenhouse" + +jobs: + build-and-push: + name: Build and Push Template Plugin Image + runs-on: [ubuntu-latest] + permissions: + contents: read + packages: write + id-token: write + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Read version from package.json + id: read_version + working-directory: ${{ env.PACKAGE_PATH }} + run: | + TEMPLATE_VERSION=$(jq -r '.version' package.json) + echo "Template version is $TEMPLATE_VERSION" + echo "IMAGE_VERSION=$TEMPLATE_VERSION" >> $GITHUB_OUTPUT + + # Login against a Docker registry + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Check if the image with this SHA already exists + - name: Check if image exists in registry + id: check-image + continue-on-error: true + run: | + if docker pull ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }}; then + echo "Image with SHA ${{ github.sha }} already exists in the registry" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + # Set up Docker Buildx + - name: Set up Docker Buildx + if: steps.check-image.outputs.exists != 'true' + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + with: + driver-opts: | + image=moby/buildkit:latest + + - name: Generate Docker metadata + id: meta + if: steps.check-image.outputs.exists != 'true' + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{ steps.read_version.outputs.IMAGE_VERSION }} + type=sha,enable=true,format=short,prefix= + type=raw,value=${{ github.sha }} + type=ref,event=pr + labels: | + org.opencontainers.image.description=${{env.DESCRIPTION}} + org.opencontainers.image.title=Juno-Template-Plugin + org.opencontainers.image.url=https://github.com/cloudoperators/juno/tree/main/apps/template + org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template + annotations: | + manifest:org.opencontainers.image.description=${{env.DESCRIPTION}} + manifest:org.opencontainers.image.title=Juno-Template-Plugin + manifest:org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template + manifest:org.opencontainers.image.url=https://github.com/cloudoperators/juno/tree/main/apps/template + + # Build and push Docker image + - name: Build and push Docker image ${{ steps.meta.outputs.tags }} + id: build-image + if: steps.check-image.outputs.exists != 'true' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + file: ${{ env.PACKAGE_PATH }}/docker/Dockerfile + provenance: false + diff --git a/apps/greenhouse/src/components/Extension.tsx b/apps/greenhouse/src/components/Extension.tsx index 015ddcb025..629f870692 100644 --- a/apps/greenhouse/src/components/Extension.tsx +++ b/apps/greenhouse/src/components/Extension.tsx @@ -16,11 +16,16 @@ type ExtensionProps = { } function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) { - const { isLoading, containerRef } = usePluginLoader({ + // PoC: Hardcode template plugin path + // TODO: Replace with dynamic plugin registry (usePluginConfig hook) + const pluginPath = config.name === "template" ? "/plugins/template/index.js" : undefined + + const { isLoading, error, containerRef } = usePluginLoader({ pluginName: config.name, config, appProps, pluginAuth, + pluginPath, }) if (isLoading) { @@ -31,6 +36,16 @@ function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) { ) } + if (error) { + return ( +
+
+ Error loading plugin {config.name}: {error.message} +
+
+ ) + } + return
} diff --git a/apps/greenhouse/src/hooks/usePluginLoader.ts b/apps/greenhouse/src/hooks/usePluginLoader.ts index ecbb208c8c..26f53b74f0 100644 --- a/apps/greenhouse/src/hooks/usePluginLoader.ts +++ b/apps/greenhouse/src/hooks/usePluginLoader.ts @@ -12,24 +12,43 @@ import type { PluginModule } from "@cloudoperators/juno-app-supernova" // Cache loaded modules at the module level (persists across component mounts) const moduleCache = new Map() -const getApp = async (appName: string): Promise => { +const getApp = async (appName: string, pluginPath?: string): Promise => { // Return cached module immediately if available if (moduleCache.has(appName)) { return moduleCache.get(appName)! } - // Load the module let module: PluginModule | null = null - switch (appName) { - case "supernova": - module = await import("@cloudoperators/juno-app-supernova") - break - case "doop": - module = await import("@cloudoperators/juno-app-doop") - break - case "heureka": - module = await import("@cloudoperators/juno-app-heureka") - break + + // Try loading from plugin path (relative path = same origin via Ingress, no CORS!) + if (pluginPath) { + try { + console.log(`Loading plugin ${appName} from ${pluginPath}`) + // @vite-ignore allows dynamic import of paths/URLs + module = await import(/* @vite-ignore */ pluginPath) + console.log(`Successfully loaded plugin ${appName}`) + } catch (error) { + console.error(`Failed to load plugin ${appName} from ${pluginPath}:`, error) + // Continue to fallback logic + } + } + + // Fallback to bundled apps (backward compatibility) + if (!module) { + switch (appName) { + case "supernova": + module = await import("@cloudoperators/juno-app-supernova") + break + case "doop": + module = await import("@cloudoperators/juno-app-doop") + break + case "heureka": + module = await import("@cloudoperators/juno-app-heureka") + break + default: + console.warn(`Unknown plugin: ${appName}, no fallback available`) + return null + } } // Cache it for next time @@ -45,22 +64,27 @@ type UsePluginLoaderParams = { config: any appProps: AppProps pluginAuth: AuthStore + pluginPath?: string // NEW: Path to load plugin from (relative = same origin, absolute = external CDN) } type UsePluginLoaderResult = { isLoading: boolean + error: Error | null // NEW: Track loading errors containerRef: React.RefObject } /** * Custom hook to handle plugin loading and mounting * Loads plugins dynamically with caching and handles mount/unmount lifecycle + * Supports both bundled plugins and remote plugins + * Remote plugins use relative paths (same origin via Ingress) - no CORS needed! */ export function usePluginLoader({ pluginName, config, appProps, pluginAuth, + pluginPath, // NEW }: UsePluginLoaderParams): UsePluginLoaderResult { const router = useRouter() const containerRef = useRef(null) @@ -69,6 +93,7 @@ export function usePluginLoader({ const cachedModule = moduleCache.get(pluginName) const [app, setApp] = useState(cachedModule || null) const [isLoading, setIsLoading] = useState(!cachedModule) // Only show loading if not cached + const [error, setError] = useState(null) // NEW // Load the plugin module dynamically (only if not already loaded) useEffect(() => { @@ -80,8 +105,9 @@ export function usePluginLoader({ const loadApp = async () => { setIsLoading(true) + setError(null) try { - const appModule = await getApp(pluginName) + const appModule = await getApp(pluginName, pluginPath) // Pass pluginPath if (!cancelled) { setApp(appModule) setIsLoading(false) @@ -89,8 +115,9 @@ export function usePluginLoader({ } catch (error) { if (!cancelled) { setIsLoading(false) + setError(error instanceof Error ? error : new Error(String(error))) + console.error(`Error loading plugin ${pluginName}:`, error) } - throw error } } @@ -99,7 +126,7 @@ export function usePluginLoader({ return () => { cancelled = true } - }, [pluginName, cachedModule]) + }, [pluginName, pluginPath, cachedModule]) // Mount the app once it's loaded useEffect(() => { @@ -107,20 +134,29 @@ export function usePluginLoader({ return } - app.mount(containerRef.current, { - props: { - ...config.props, - embedded: true, - basePath: `${router.basepath === "/" ? "" : router.basepath}/${config.id}`, - enableHashedRouting: appProps?.enableHashedRouting || false, - auth: pluginAuth, - }, - }) + try { + app.mount(containerRef.current, { + props: { + ...config.props, + embedded: true, + basePath: `${router.basepath === "/" ? "" : router.basepath}/${config.id}`, + enableHashedRouting: appProps?.enableHashedRouting || false, + auth: pluginAuth, + }, + }) + } catch (error) { + console.error(`Error mounting plugin ${pluginName}:`, error) + setError(error instanceof Error ? error : new Error(String(error))) + } return () => { - app.unmount() + try { + app.unmount() + } catch (error) { + console.error(`Error unmounting plugin ${pluginName}:`, error) + } } }, [app, config, router, pluginAuth, appProps]) - return { isLoading, containerRef } + return { isLoading, error, containerRef } } diff --git a/apps/greenhouse/src/routes/__root.tsx b/apps/greenhouse/src/routes/__root.tsx index 456458d33b..fae640205e 100644 --- a/apps/greenhouse/src/routes/__root.tsx +++ b/apps/greenhouse/src/routes/__root.tsx @@ -41,7 +41,29 @@ function RootComponent() { // fetch configs from kubernetes getPluginConfigs() .then((kubernetesConfig: any) => { - receiveConfig(kubernetesConfig) + // PoC: Add hardcoded template plugin config + const templateConfig = { + id: "template", + name: "template", + displayName: "Template (PoC)", + core: false, + version: "latest", + url: null, + weight: 999, // Show at the end + navType: "app", + navigable: true, + props: { + id: "template", + }, + } + + // Merge with Kubernetes configs + const allConfigs = { + ...kubernetesConfig, + template: templateConfig, + } + + receiveConfig(allConfigs) }) .catch((error: any) => { // error fetching configs diff --git a/apps/template/docker/Dockerfile b/apps/template/docker/Dockerfile new file mode 100644 index 0000000000..91c32dc507 --- /dev/null +++ b/apps/template/docker/Dockerfile @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors +# SPDX-License-Identifier: Apache-2.0 + +FROM node:22-alpine as build + +LABEL org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template +LABEL org.opencontainers.image.description="Juno Template plugin - ES module library for external plugin PoC" +LABEL org.opencontainers.image.licenses=Apache-2.0 + +WORKDIR /app + +ADD . . +RUN npm i -g pnpm + +RUN pnpm install && pnpx turbo build --filter @cloudoperators/juno-app-template + +FROM nginx:alpine + +WORKDIR /usr/share/nginx/html + +# Copy built app +COPY --from=build /app/apps/template/build /usr/share/nginx/html/ + +# Use default nginx config (serves files from /usr/share/nginx/html/) +# No custom config needed - default works fine + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/template/vite.config.ts b/apps/template/vite.config.ts index 7c9f0e5888..3ebe9ecc34 100644 --- a/apps/template/vite.config.ts +++ b/apps/template/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig(({ mode }: { mode: string }): UserConfig => { define: { "process.env": {}, + "process.env.NODE_ENV": JSON.stringify(mode === "production" ? "production" : "development"), }, plugins: [tailwindcss(), react(), tsconfigPaths()], @@ -45,6 +46,14 @@ export default defineConfig(({ mode }: { mode: string }): UserConfig => { formats: ["es"], fileName: () => `index.js`, }, + + // For PoC: Bundle everything including React + // Production: Should externalize and use import maps + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, }, } })