Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/build-push-template-image.yaml
Original file line number Diff line number Diff line change
@@ -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]
Comment on lines +20 to +22
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

17 changes: 16 additions & 1 deletion apps/greenhouse/src/components/Extension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -31,6 +36,16 @@ function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) {
)
}

if (error) {
return (
<div>
<div>
Error loading plugin {config.name}: {error.message}
</div>
</div>
)
}

return <div key={id} ref={containerRef}></div>
}

Expand Down
88 changes: 62 additions & 26 deletions apps/greenhouse/src/hooks/usePluginLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PluginModule>()

const getApp = async (appName: string): Promise<PluginModule | null> => {
const getApp = async (appName: string, pluginPath?: string): Promise<PluginModule | null> => {
// Return cached module immediately if available
if (moduleCache.has(appName)) {
return moduleCache.get(appName)!
}
Comment on lines 16 to 19

// 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
Expand All @@ -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<HTMLDivElement | null>
}

/**
* 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<HTMLDivElement>(null)
Expand All @@ -69,6 +93,7 @@ export function usePluginLoader({
const cachedModule = moduleCache.get(pluginName)
const [app, setApp] = useState<PluginModule | null>(cachedModule || null)
const [isLoading, setIsLoading] = useState(!cachedModule) // Only show loading if not cached
const [error, setError] = useState<Error | null>(null) // NEW

// Load the plugin module dynamically (only if not already loaded)
useEffect(() => {
Expand All @@ -80,17 +105,19 @@ 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)
}
} catch (error) {
if (!cancelled) {
setIsLoading(false)
setError(error instanceof Error ? error : new Error(String(error)))
console.error(`Error loading plugin ${pluginName}:`, error)
}
throw error
}
}

Expand All @@ -99,28 +126,37 @@ export function usePluginLoader({
return () => {
cancelled = true
}
}, [pluginName, cachedModule])
}, [pluginName, pluginPath, cachedModule])

// Mount the app once it's loaded
useEffect(() => {
if (!app || !containerRef.current) {
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 }
}
24 changes: 23 additions & 1 deletion apps/greenhouse/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Comment on lines +60 to +64

receiveConfig(allConfigs)
})
.catch((error: any) => {
// error fetching configs
Expand Down
29 changes: 29 additions & 0 deletions apps/template/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 4 in apps/template/docker/Dockerfile

View workflow job for this annotation

GitHub Actions / Build and Push Template Plugin Image

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

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;"]
9 changes: 9 additions & 0 deletions apps/template/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down Expand Up @@ -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,
},
},
},
}
})
Loading