diff --git a/Guide-Local-development.md b/Guide-Local-development.md new file mode 100644 index 0000000..0481ce2 --- /dev/null +++ b/Guide-Local-development.md @@ -0,0 +1,164 @@ +# Guide Local Development + +Este documento explica o processo de desenvolvimento local do DevPortal com suporte completo a plugins dinâmicos. + +## Pré-requisitos + +### Dependências Obrigatórias + +1. **Node.js** (versão 20 ou 22) +2. **Yarn** (versão 4.4.1+) +3. **Python 3** (para execução do script de plugins) +4. **Make** (para build do projeto) + +### Dependências para Plugins OCI + +Para usar plugins baseados em container OCI, você precisa instalar: + +1. **Skopeo** - Ferramenta para trabalhar com registries de container + ```bash + # Ubuntu/Debian + sudo apt-get install skopeo + + # macOS (via Homebrew) + brew install skopeo + + # RHEL/CentOS/Fedora + sudo dnf install skopeo + ``` + +2. **Docker** (opcional, mas recomendado para desenvolvimento) + - Necessário se você quiser testar plugins OCI localmente + - Registro Docker deve estar acessível + +### Verificação de Dependências + +```bash +# Verificar se todas as dependências estão instaladas +node --version # Deve ser 20.x ou 22.x +yarn --version # Deve ser 4.4.1+ +python3 --version # Deve ser 3.x +make --version # Deve estar disponível +skopeo --version # Necessário apenas para plugins OCI +``` + +## Arquitetura de Plugins Dinâmicos + +### Arquivos de Configuração + +1. **`dynamic-plugins.default.yaml`** + - Contém plugins **embutidos** na aplicação base + - Inclui plugins de frontend e backend pré-configurados + - Plugins locais (caminhos `./dynamic-plugins/dist/...`) + - Configurações padrão para desenvolvimento + +2. **`dynamic-plugins.yaml`** + - Contém plugins **externos** (OCI e NPM) + - Inclui referência ao `dynamic-plugins.default.yaml` via `includes` + - Suporta plugins de container OCI (`oci://docker.io/...`) + - Suporta plugins NPM remotos e locais + - Configurações específicas de montagem e comportamento + +### Sistema de Instalação de Plugins + +O script `check_dynamic_plugins.py` implementa um sistema robusto de gerenciamento de plugins: + +#### Funcionalidades Principais + +1. **Download e Instalação** + - **Plugins OCI**: Usa `skopeo` para baixar imagens de container + - Requer `skopeo` instalado no sistema + - Suporta registries Docker (`docker://`) e OCI (`oci://`) + - Exemplo: `oci://docker.io/user/plugin:v1.0.0!path/in/container` + - **Plugins NPM**: Usa `npm pack` para baixar pacotes + - Funciona com registries NPM públicos e privados + - Exemplo: `@backstage/plugin-catalog` ou `./local-plugin` + - **Plugins Locais**: Suporta caminhos relativos (`./`) + - Para desenvolvimento local de plugins + - Exemplo: `./dynamic-plugins/dist/my-plugin` + +2. **Verificação de Integridade** + - Validação de hash SHA para pacotes remotos + - Verificação de assinatura de pacotes OCI + - Proteção contra zip bombs (limite de 20MB por arquivo) + +3. **Gerenciamento de Estado** + - Sistema de lock para evitar instalações concorrentes + - Cache inteligente baseado em hash de configuração + - Políticas de pull: `IfNotPresent`, `Always` + - Detecção automática de mudanças na configuração + +4. **Geração de Configuração** + - Cria `app-config.dynamic-plugins.yaml` automaticamente + - Merge de configurações de plugins individuais + - Resolução de conflitos de configuração + +#### Estrutura de Diretórios + +``` +dynamic-plugins-root/ +├── app-config.dynamic-plugins.yaml # Config gerada automaticamente +├── plugin-name-1/ # Plugin instalado +│ ├── dynamic-plugin-config.hash # Hash da configuração +│ └── ... (arquivos do plugin) +└── plugin-name-2/ + ├── dynamic-plugin-image.hash # Hash da imagem OCI + └── ... (arquivos do plugin) +``` + +### Scripts de Desenvolvimento + +1. **`yarn init-local`** + - Executa build completo (`make full`) + - Instala plugins dinâmicos (`yarn check-dynamic-plugins`) + - Inicia aplicação em modo desenvolvimento (`yarn dev-local`) + +2. **`yarn dev-local`** + - Inicia backend e frontend + - Carrega configurações: `app-config.local.yaml` + `app-config.dynamic-plugins.yaml` + - Suporte completo a hot-reload + +3. **`yarn check-dynamic-plugins`** + - Executa `scripts/check_dynamic_plugins.sh` + - Chama o script Python para instalação de plugins + +> **Nota**: Se necessário, dar permissão de execução ao script: + ```bash + chmod +x ./scripts/check_dynamic_plugins.sh + ``` + +## Configurações Específicas + +### Branding e Temas + +No `app-config.yaml`: +- **Logo dinâmico**: Suporte a variantes `light` e `dark` +- **Tema personalizado**: Configuração `appBarBackgroundScheme` para controle de tema +- **Largura customizável**: `fullLogoWidth` para ajuste de dimensões + +### Autenticação + +- Método customizado para autenticação GitHub (desenvolvimento) +- Configuração de chaves de serviço para autenticação backend + +### Componentes + +- **Root Component**: Lógica para ocultar logo duplicado quando o ponto de montagem `CompanyLogo` tiver conteúdo para o `logo.light` e `logo.dark`; +- **CompanyLogo**: Suporte completo a logos temáticos e configuração dinâmica + +## Como Usar + +1. **Desenvolvimento Local Completo**: + ```bash + yarn init-local + ``` + +2. **Apenas Instalar Plugins**: + ```bash + yarn check-dynamic-plugins + ``` + +3. **Desenvolvimento sem Reinstalar Plugins**: + ```bash + yarn dev-local + ``` diff --git a/app-config.yaml b/app-config.yaml index 4787f8c..36206a3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -5,8 +5,9 @@ app: ga4: measurementId: soueu branding: - fullLogo: https://veecode-platform.github.io/support/logos/logo.svg - fullLogoDark: https://veecode-platform.github.io/support/logos/logo-black.svg + fullLogo: + light: https://veecode-platform.github.io/support/logos/logo-black.svg + dark: https://veecode-platform.github.io/support/logos/logo.svg iconLogo: https://veecode-platform.github.io/support/logos/logo-mobile.png fullLogoWidth: 150 theme: @@ -15,11 +16,13 @@ app: palette: navigation: background: "#222222" # your fixed light theme sidebar background + appBarBackgroundScheme: light dark: variant: "backstage" palette: navigation: background: "#222222" # same fixed color for dark theme + appBarBackgroundScheme: dark packages: all organization: diff --git a/dynamic-plugins.default.yaml b/dynamic-plugins.default.yaml new file mode 100644 index 0000000..bfba0b0 --- /dev/null +++ b/dynamic-plugins.default.yaml @@ -0,0 +1,82 @@ +plugins: + + # Group: Global floating action button + - package: ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-global-floating-action-button-dynamic + disabled: true + pluginConfig: + dynamicPlugins: + frontend: + red-hat-developer-hub.backstage-plugin-global-floating-action-button: + mountPoints: + - mountPoint: application/listener + importName: DynamicGlobalFloatingActionButton + + # Group: Scaffolder + - package: ./dynamic-plugins/dist/backstage-community-plugin-scaffolder-backend-module-kubernetes-dynamic + disabled: false + + # Group: Tech Radar + - package: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar-backend-dynamic + disabled: true + pluginConfig: + techRadar: + url: ${TECH_RADAR_DATA_URL} + + - package: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic + disabled: true + pluginConfig: + dynamicPlugins: + frontend: + backstage-community.plugin-tech-radar: + apiFactories: + - importName: TechRadarApi + appIcons: + - name: techRadar + importName: TechRadarIcon + dynamicRoutes: + - path: /tech-radar + importName: TechRadarPage + menuItem: + icon: techRadar + text: menuItem.techRadar + textKey: menuItem.techRadar + config: + props: + width: 1500 + height: 800 + + # Group: Github Org + - package: ./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic + disabled: true + + # Group: Kubernetes + - package: ./dynamic-plugins/dist/backstage-plugin-kubernetes-dynamic + disabled: true + pluginConfig: + kubernetes: + serviceLocatorMethod: + type: 'multiTenant' + clusterLocatorMethods: + - type: 'config' + clusters: + - name: ${K8S_CLUSTER_NAME} + url: ${K8S_CLUSTER_URL} + authProvider: 'serviceAccount' + skipTLSVerify: true + serviceAccountToken: ${K8S_CLUSTER_TOKEN} + - package: ./dynamic-plugins/dist/backstage-plugin-kubernetes-dynamic + disabled: true + pluginConfig: + dynamicPlugins: + frontend: + backstage.plugin-kubernetes: + mountPoints: + - mountPoint: entity.page.kubernetes/cards + importName: EntityKubernetesContent + config: + layout: + gridColumn: "1 / -1" + if: + anyOf: + - hasAnnotation: backstage.io/kubernetes-id + - hasAnnotation: backstage.io/kubernetes-namespace \ No newline at end of file diff --git a/dynamic-plugins.yaml b/dynamic-plugins.yaml new file mode 100644 index 0000000..a47badf --- /dev/null +++ b/dynamic-plugins.yaml @@ -0,0 +1,449 @@ +includes: + - dynamic-plugins.default.yaml +plugins: + # VeeCode GlobalHeader + - package: oci://docker.io/valberjunior/veecode-global-header:v5.0.0!veecode-platform-plugin-veecode-global-header + disabled: false + pluginConfig: + dynamicPlugins: + frontend: + default.main-menu-items: + menuItems: + default.create: + title: '' + veecode-platform.plugin-veecode-global-header: + mountPoints: + - mountPoint: application/header + importName: GlobalHeader + config: + position: above-sidebar # above-main-content | above-sidebar + - mountPoint: global.header/component + importName: CompanyLogo + config: + priority: 200 + props: + to: '/' + logo: + light: https://veecode-platform.github.io/support/logos/logo-black.svg + dark: https://veecode-platform.github.io/support/logos/logo.svg + - mountPoint: global.header/component + importName: SearchComponent + config: + priority: 100 + - mountPoint: global.header/component + importName: Spacer + config: + priority: 99 + props: + growFactor: 0 + + - mountPoint: global.header/component + importName: HeaderIconButton + config: + priority: 90 + props: + title: Self-service + titleKey: create.title + icon: add + to: create + + - mountPoint: global.header/component + importName: ToggleThemeButton + config: + priority: 87 + + - mountPoint: global.header/component + importName: StarredDropdown + config: + priority: 85 + + - mountPoint: global.header/component + importName: ApplicationLauncherDropdown + config: + priority: 82 + + - mountPoint: global.header/application-launcher + importName: MenuItemLink + config: + section: Documentation + priority: 150 + props: + title: Developer Hub + titleKey: applicationLauncher.developerHub + type: doc + link: https://docs.redhat.com/en/documentation/red_hat_developer_hub + + - mountPoint: global.header/application-launcher + importName: MenuItemLink + config: + section: Developer Tools + priority: 100 + props: + title: RHDH Local + titleKey: applicationLauncher.rhdhLocal + type: 'extension' + link: https://github.com/redhat-developer/rhdh-local + + - mountPoint: global.header/component + importName: HelpDropdown + config: + priority: 80 + + - mountPoint: global.header/help + importName: SupportButton + config: + priority: 10 + props: + type: support + + - mountPoint: global.header/component + importName: NotificationButton + config: + priority: 70 + - mountPoint: global.header/component + importName: Divider + config: + priority: 50 + - mountPoint: global.header/component + importName: ProfileDropdown + config: + priority: 10 + - mountPoint: global.header/profile + importName: MenuItemLink + config: + priority: 100 + props: + title: Settings + titleKey: profile.settings + link: /settings + type: settings + - mountPoint: global.header/profile + importName: MenuItemLink + config: + priority: 90 + props: + title: My profile + titleKey: profile.myProfile + type: user + - mountPoint: global.header/profile + importName: LogoutButton + config: + priority: 10 + + + # VeeCode Homepage + - package: '@veecode-platform/plugin-veecode-homepage-dynamic@0.2.3' + disabled: false + integrity: sha512-gr+Bd4ocjuit1mxXgpZrtiV5hDGbguw3E7uGQ/0N1TGZ9OUx2/6fNBrQ/WFOlYD7o7ToawOy/u8du6gk+0LiVw== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.plugin-veecode-homepage: + dynamicRoutes: + - path: / + importName: VeecodeHomepagePage + config: + props: + width: 1500 + height: 800 + # Cluster Explorer + - package: '@veecode-platform/backstage-plugin-cluster-explorer-dynamic@0.1.1' + disabled: false + integrity: sha512-AzQbtKUlbi3hpjynqFvyFn8gfbb6iaELUis5rgMjG7CyMCd6CxYYd1vgXMONJPXjdbfguP54A8rYSvvNCNvhdw== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.backstage-plugin-cluster-explorer-dynamic: + dynamicRoutes: + - path: /cluster-explorer + importName: ClusterExplorerPage + menuItem: + icon: cluster + text: Clusters + mountPoints: + - mountPoint: entity.page.cluster-overview/cards + importName: ClusterOverview + config: + layout: + gridColumn: '1 / -1' + if: + anyOf: + - isKind: Cluster + entityTabs: + - path: / + title: About #rewrite old overview to about + #priority: -1 # remove original overview tab + mountPoint: entity.page.overview + - path: /overview + title: Cluster Overview + #priority: 10 #higher means goes first + mountPoint: entity.page.cluster-overview + # Database Explorer + - package: '@veecode-platform/plugin-database-explorer-dynamic@0.1.0' + disabled: true + integrity: sha512-UMptKBvVv+zgNH9KfaRqteFaGQdVmYjvaMmCJeiJV5lJD14bnSOPTn9AxE3cyhZ4SfZu6G60RQ9hYDhqBcucDA== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.plugin-database-explorer-dynamic: + entityTabs: + - path: / + title: overview + mountPoint: entity.page.overview + importName: DatabaseOverviewTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf|anyOf|oneOf: + - isDatabaseKind + - isKind: Database + # Environment Explorer + - package: '@veecode-platform/plugin-environment-dynamic@0.1.0' + disabled: true + integrity: sha512-vqgGXqt4yKUm3sdrfduRymJdarO4xTFaRNr+DfTBC0aXTgUV3VjA45vogpq2BjPgDJhgqS1guJPNHsN5eOb3ow== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.plugin-environment-explorer-dynamic: + entityTabs: + - path: / + title: overview + mountPoint: entity.page.overview + importName: EnvironmentOverviewTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf|anyOf|oneOf: + - isEnvironmentKind + - isKind: Environment + # # Theme + # - package: '@veecode-platform/backstage-plugin-theme@1.1.1' + # disabled: false + # integrity: sha512-PKvOgAUtzEFGl8D/5GD1fyE66rnLJMbty5vfoY8ajEWHuggYVxpxOW+WFS3DgQS00N06nqckcjigNeBCIUd8GQ== + + # Vault Explorer + - package: '@veecode-platform/plugin-vault-explorer-dynamic@0.1.0' + disabled: true + integrity: sha512-7JuK9KltuZZvg4znD11BeafN9iYnTf+Hny88BIsTkwLyam2LRW6TfDrkmuqrwSHnvDvDb2HUDfffdEvO5Am4Rg== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.plugin-vault-explorer-dynamic: + entityTabs: + - path: / + title: overview + mountPoint: entity.page.overview + importName: VaultOverviewTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf|anyOf|oneOf: + - isVaultKind + - isKind: Vault + # Vee + # - package: '@veecode-platform/backstage-plugin-vee-dynamic@1.0.1' + # disabled: true + # integrity: sha512-vtH6GVoOYO/WobLMjZUj2nLyJGpK2yUnW7xLkLdlgYIOxPZZSc8MMCGdUJbANW9QFHsz6oBmRMUkLEXOu4dSMg== + # VeeCode Tenant Explorer + - package: '@veecode-platform/backstage-plugin-tenant-explorer-dynamic@0.1.0' + disabled: true + integrity: sha512-feotRa1r0zsZZPDCgrsRiVpx34t+8nwDubZ+4gkrRV3QiHUtddWPGFnlpls7zSPNGyq06cwY25KZ/Mczx4b7Ew== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.backstage-plugin-tenant-explorer-dynamic: + appIcons: + - name: tenantIcon + importName: TenantNavItem + dynamicRoutes: + - path: /tenant-explorer + importName: TenantExplorerPage + menuItem: + icon: tenantIcon + text: Tenants + menuItems: + rbac: + parent: admin + icon: tenantIcon + + # VeeCode Modules Extension (VeeCode Processors) + - disabled: true + package: '@veecode-platform/plugin-veecode-platform-module-dynamic@0.1.0' + integrity: sha512-OnZn53Qg5r2qlkISs/dFfeZvD2/NL6ARXj3KRFKRwrO3JvSATfxABIuft5e5VlnNABUkFA/Dcvcj4eHcFnrUlA== + + # VeeCode Custom Actions + - disabled: true + package: oci://docker.io/valberjunior/backstage-veecode-extensions:v4.0.0!veecode-platform-backstage-plugin-scaffolder-backend-module-veecode-extensions-wrapper + + # Github Workflows + - package: '@veecode-platform/backstage-plugin-github-workflows-dynamic@0.1.0' + disabled: true + integrity: sha512-gdCO5aSVNs2gwAU6bx17Bdt34G5xtb8EXCoCDIeKTlG7/vIB8xzCIxPIiNVLWkAp6v8lw+kdnF25/3GceOhskQ== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.plugin-github-workflows-dynamic: + mountPoints: + - mountPoint: entity.page.overview/cards + importName: GithubWorkflowsOverviewContent + config: + layout: + gridColumnEnd: + lg: 'span 8' + md: 'span 6' + xs: 'span 12' + if: + allOf: + - isGithubWorkflowsAvailable + - mountPoint: entity.page.ci/cards + importName: GithubWorkflowsTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf: + - isGithubAvailable + + # Gitlab Pipelines + - package: '@veecode-platform/backstage-plugin-gitlab-pipelines-dynamic@0.1.0' + disabled: true + integrity: sha512-IPIfKbdShbUng8S9QygPWx9wmn7hbw1W6iAXZ4xZC9REN05IZWedWjawp1iipXfMG3oA9c3L4qAC7ztMftbEfQ== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.backstage-plugin-gitlab-pipelines-dynamic: + mountPoints: + - mountPoint: entity.page.overview/cards + importName: GitlabPipelinesOverviewContent + config: + layout: + gridColumnEnd: + lg: 'span 8' + md: 'span 6' + xs: 'span 12' + if: + allOf: + - isGitlabJobsAvailable + - mountPoint: entity.page.ci/cards + importName: GitlabPipelinesTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf: + - isGitlabAvailable + + # Infracost + - package: '@veecode-platform/backstage-plugin-infracost-dynamic@0.1.0' + disabled: true + integrity: sha512-zGOe18uSIYBApBigXu1T4LjEvfGOvr/W1fBAzdvMJE1q3QNOrpMi/bPXalSIQuS6KiWyX6ps/JF9IAcpZSbCNA== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.backstage-plugin-infracost-dynamic: + entityTabs: + - path: /infracost + title: infracost + mountPoint: entity.page.infracost + importName: InfracostTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf: + - isInfracostAvailable + + # Kong Service Manager + - package: '@veecode-platform/plugin-kong-service-manager-dynamic@0.1.0' + disabled: true + integrity: sha512-oaUSb51PBvuqtZkt9bfYEH8xLS/jOLmnJURa2+ypIboXkoWlDid9cnUbbBjx4+qL2WY7JmZ7eu9hjefgti3w0w== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.plugin-kong-service-manager-dynamic: + mountPoints: + - mountPoint: entity.page.kong/cards + importName: KongServiceManagerTabContent + config: + layout: + gridColumn: '1 / -1' + # if: + # allOf: + # - isKongServiceManagerAvailable + + # Kubernetes GPT Analyzer + - package: '@veecode-platform/backstage-plugin-kubernetes-gpt-analyzer-dynamic@0.1.0' + disabled: true + integrity: sha512-ZajRQmYkYcP7MJYva0PISqTqJ8+UDXJL2hxIAgYhprOPMzEqXMtze+/wvPAjcgl/nOuNt+Q+ZQNFRDqZ2BCQUg== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.backstage-plugin-kubernetes-gpt-analyzer-dynamic: + - mountPoint: entity.page.overview/cards + importName: KubernetesGPTAnalyzerOverviewContent + config: + layout: + gridColumnEnd: + lg: 'span 4' + md: 'span 6' + xs: 'span 12' + if: + allOf: + - isKubernetesAvailable + entityTabs: + - path: /kubernetes-gpt-analyzer + title: Kubernetes GPT + mountPoints: entity.page.kubernetes-gpt-analyzer + importName: KubernetesGPTAnalyzerTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf: + - isKubernetesAvailable + + # Zora OSS + - package: '@veecode-platform/backstage-plugin-zora-oss-dynamic@0.1.0' + disabled: true + integrity: sha512-wayuoYWaOqp9neoRePEzo/qbBMKR3vBta/meOGdJ8+p6Ze4KjYWgXKjkKzsxSdmg2uYpDwgrAv/+gedmweJ9Xw== + pluginConfig: + dynamicPlugins: + frontend: + veecode-platform.backstage-plugin-zora-oss-dynamic: + mountPoints: + - mountPoint: entity.page.overview/cards + importName: ZoraOverviewProjectContent + config: + layout: + gridColumnEnd: + lg: 'span 6' + md: 'span 6' + xs: 'span 12' + if: + allOf|anyOf|oneOf: + - isZoraProject + entityTabs: + - path: /zora-project + title: Zora + mountPoint: entity.page.zora + importName: ZoraOSSProjectTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf|anyOf|oneOf: + - isZoraProject + - path: /zora-cluster + mountPoint: entity.page.zora + importName: ZoraOSSClusterTabContent + config: + layout: + gridColumn: '1 / -1' + if: + allOf|anyOf|oneOf: + - isZoraAvailable + - isZoraCluster + - isKind: Cluster \ No newline at end of file diff --git a/package.json b/package.json index 9257587..60ef02d 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "node": "20 || 22" }, "scripts": { - "init-local": "make full && yarn check-dynamic-plugins", + "init-local": "make full && yarn check-dynamic-plugins && yarn dev-local", + "check-dynamic-plugins": "sh scripts/check_dynamic_plugins.sh", "start": "NODE_OPTIONS=--no-node-snapshot turbo run start --filter=backend", "start-backend": "yarn workspace backend start", - "dev": "NODE_OPTIONS=--no-node-snapshot turbo run start --filter=backend --filter=app --config ../../app-config.yaml --config ../../app-config.dynamic-plugins.yaml", - "dev-local": "NODE_OPTIONS=--no-node-snapshot turbo run start --filter=backend --filter=app -- --config ../../app-config.yaml --config ../../app-config.local.yaml --config ../../app-config.dynamic-plugins.local.yaml", + "dev": "NODE_OPTIONS=--no-node-snapshot turbo run start --filter=backend --filter=app --config ../../app-config.yaml --config ../../dynamic-plugins-root/app-config.dynamic-plugins.yaml", + "dev-local": "NODE_OPTIONS=--no-node-snapshot turbo run start --filter=backend --filter=app -- --config ../../app-config.local.yaml --config ../../dynamic-plugins-root/app-config.dynamic-plugins.yaml", "dev-docker": "NODE_OPTIONS=--no-node-snapshot turbo run start --filter=backend --filter=app -- --config ../../app-config.yaml --config ../../app-config.local.yaml --config ../../app-config.dynamic-plugins.local.yaml", "build": "turbo run build", "build:backend": "yarn tsc && yarn workspace backend build", diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index eabb339..957f05c 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -65,6 +65,7 @@ import { MenuIcon } from './MenuIcon'; import { SidebarLogo } from './SidebarLogo'; import SignOutElement from './signOut'; import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; +import { DynamicPluginsConfig } from './types'; type StylesProps = { aboveSidebarHeaderHeight?: number; @@ -282,8 +283,16 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { const { dynamicRoutes, menuItems } = useContext(DynamicRootContext); const configApi = useApi(configApiRef); + const configDynamicPlugins = configApi.getOptional('dynamicPlugins') as DynamicPluginsConfig; + const globalHeaderConfigKeys = Object.keys(configDynamicPlugins.frontend).find(key => key.includes('global-header')); + const globalHeaderConfig = configDynamicPlugins.frontend[`${globalHeaderConfigKeys}`]; + + // Check if the global.header/component mountPoint exists and if logo.light and logo.dark are defined + const globalHeaderHasLogo = globalHeaderConfig?.mountPoints?.some( + (mount: any) => mount.importName === 'CompanyLogo' && mount.config.props.logo.light !== undefined && mount.config.props.logo.dark !== undefined + ); + const showLogo = globalHeaderHasLogo ? false : true; - const showLogo = configApi.getOptionalBoolean('app.sidebar.logo') ?? true; const showSearch = configApi.getOptionalBoolean('app.sidebar.search') ?? false; const showSettings = diff --git a/packages/app/src/components/Root/SidebarLogo.tsx b/packages/app/src/components/Root/SidebarLogo.tsx index d304bdb..c7a0304 100644 --- a/packages/app/src/components/Root/SidebarLogo.tsx +++ b/packages/app/src/components/Root/SidebarLogo.tsx @@ -24,6 +24,7 @@ import { makeStyles } from 'tss-react/mui'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; +import { useTheme } from '@mui/material'; const useStyles = makeStyles()({ sidebarLogo: { @@ -54,11 +55,11 @@ const LogoRender = ({ export const SidebarLogo = () => { const { classes } = useStyles(); + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; const { isOpen } = useSidebarOpenState(); const configApi = useApi(configApiRef); - const logoFullBase64URI = configApi.getOptionalString( - 'app.branding.fullLogo', - ); + const logoFullBase64URI = isDarkMode ? configApi.getOptionalString('app.branding.fullLogo.dark') : configApi.getOptionalString('app.branding.fullLogo.light'); const fullLogoWidth = configApi .getOptional('app.branding.fullLogoWidth') ?.toString(); diff --git a/packages/app/src/components/Root/types.ts b/packages/app/src/components/Root/types.ts new file mode 100644 index 0000000..2d36814 --- /dev/null +++ b/packages/app/src/components/Root/types.ts @@ -0,0 +1,45 @@ +export type DynamicPluginsConfig = { + frontend: { + [key: string]: { + dynamicRoutes?: DynamicRoute[]; + entityTabs?: EntityTab[]; + mountPoints?: MountPoint[]; + }; + } +}; + +export type DynamicRoute = { + importName: string; + menuItem?: MenuItem; + path: string; +}; + +export type MenuItem = { + icon: string; + text: string; +}; + +export type EntityTab = { + mountPoint: string; + path: string; + title: string; +}; + +export type MountPoint = { + config: MountPointConfig; +}; + +export type MountPointConfig = { + if: { + [key: string]: string | string[]; + }; + layout: { + [key: string]: + | string + | { + [key: string]: string; + }; + }; + importName: string; + moutPoint: string; +}; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8f744d2..993ef31 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -29,6 +29,9 @@ import { rbacDynamicPluginsProvider, } from './modules'; import { userSettingsBackend } from './modules/userSettings'; +import { + customGithubAuth +} from './modules/auth/githubCustom'; // Create a logger to cover logging static initialization tasks const staticLogger = WinstonLogger.create({ @@ -175,11 +178,12 @@ backend.add(import('@backstage/plugin-auth-backend')); // See https://backstage.io/docs/backend-system/building-backends/migrating#the-auth-plugin backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); // See https://backstage.io/docs/auth/guest/provider -if (process.env.ENABLE_AUTH_PROVIDER_MODULE_OVERRIDE !== 'true') { - backend.add(import('./modules/authProvidersModule')); -} else { - staticLogger.info(`Default authentication provider module disabled`); -} +// if (process.env.ENABLE_AUTH_PROVIDER_MODULE_OVERRIDE !== 'true') { +// backend.add(import('./modules/authProvidersModule')); +// } else { +// staticLogger.info(`Default authentication provider module disabled`); +// } +backend.add(customGithubAuth); // Auth Github Custom (Usado apenas para os testes) // search plugin backend.add(import('@backstage/plugin-search-backend')); diff --git a/packages/backend/src/modules/auth/githubCustom.ts b/packages/backend/src/modules/auth/githubCustom.ts new file mode 100644 index 0000000..91901d6 --- /dev/null +++ b/packages/backend/src/modules/auth/githubCustom.ts @@ -0,0 +1,55 @@ +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { githubAuthenticator } from '@backstage/plugin-auth-backend-module-github-provider'; +import { + authProvidersExtensionPoint, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { + DEFAULT_NAMESPACE, + stringifyEntityRef, +} from '@backstage/catalog-model'; + +export const customGithubAuth = createBackendModule({ + // This ID must be exactly "auth" because that's the plugin it targets + pluginId: 'auth', + // This ID must be unique, but can be anything + moduleId: 'custom-auth-provider', + register(reg) { + reg.registerInit({ + deps: { providers: authProvidersExtensionPoint }, + async init({ providers }) { + providers.registerProvider({ + // This ID must match the actual provider config, e.g. addressing + // auth.providers.github means that this must be "github". + providerId: 'github', + // Use createProxyAuthProviderFactory instead if it's one of the proxy + // based providers rather than an OAuth based one + factory: createOAuthProviderFactory({ + authenticator: githubAuthenticator, + async signInResolver({ result: { fullProfile } }, ctx) { + const userId = fullProfile.username; + if (!userId) { + throw new Error( + `GitHub user profile does not contain a username`, + ); + } + + const userEntityRef = stringifyEntityRef({ + kind: 'User', + name: userId, + namespace: DEFAULT_NAMESPACE, + }); + + return ctx.issueToken({ + claims: { + sub: userEntityRef, + ent: [userEntityRef], + }, + }); + }, + }), + }); + }, + }); + }, +}); \ No newline at end of file diff --git a/scripts/check_dynamic_plugins.py b/scripts/check_dynamic_plugins.py new file mode 100644 index 0000000..e7a771b --- /dev/null +++ b/scripts/check_dynamic_plugins.py @@ -0,0 +1,523 @@ +# +# Copyright (c) 2023 Red Hat, Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import copy +from enum import StrEnum +import hashlib +import json +import os +import sys +import tempfile +import yaml +import tarfile +import shutil +import subprocess +import base64 +import binascii +import atexit +import time +import signal + +# This script is used to install dynamic plugins in the Backstage application, +# and is available in the container image to be called at container initialization, +# for example in an init container when using Kubernetes. +# +# It expects, as the only argument, the path to the root directory where +# the dynamic plugins will be installed. +# +# Additionally, the MAX_ENTRY_SIZE environment variable can be defined to set +# the maximum size of a file in the archive (default: 20MB). +# +# The SKIP_INTEGRITY_CHECK environment variable can be defined with ("true") to skip the integrity check of remote packages +# +# It expects the `dynamic-plugins.yaml` file to be present in the current directory and +# to contain the list of plugins to install along with their optional configuration. +# +# The `dynamic-plugins.yaml` file must contain: +# - a `plugins` list of objects with the following properties: +# - `package`: the NPM package to install (either a package name or a path to a local package) +# - `integrity`: a string containing the integrity hash of the package (optional if package is local, as integrity check is not checked for local packages) +# - `pluginConfig`: an optional plugin-specific configuration fragment +# - `disabled`: an optional boolean to disable the plugin (`false` by default) +# - an optional `includes` list of yaml files to include, each file containing a list of plugins. +# +# The plugins listed in the included files will be included in the main list of considered plugins +# and possibly overwritten by the plugins already listed in the main `plugins` list. +# +# For each enabled plugin mentioned in the main `plugins` list and the various included files, +# the script will: +# - call `npm pack` to get the package archive and extract it in the dynamic plugins root directory +# - if the package comes from a remote registry, verify the integrity of the package with the given integrity hash +# - merge the plugin-specific configuration fragment in a global configuration file named `app-config.dynamic-plugins.yaml` +# + +class PullPolicy(StrEnum): + IF_NOT_PRESENT = 'IfNotPresent' + ALWAYS = 'Always' + # NEVER = 'Never' not needed + +class InstallException(Exception): + """Exception class from which every exception in this library will derive.""" + pass + +RECOGNIZED_ALGORITHMS = ( + 'sha512', + 'sha384', + 'sha256', +) + +def merge(source, destination, prefix = ''): + for key, value in source.items(): + if isinstance(value, dict): + # get node or create one + node = destination.setdefault(key, {}) + merge(value, node, key + '.') + else: + # if key exists in destination trigger an error + if key in destination and destination[key] != value: + raise InstallException(f"Config key '{ prefix + key }' defined differently for 2 dynamic plugins") + + destination[key] = value + + return destination + +def maybeMergeConfig(config, globalConfig): + if config is not None and isinstance(config, dict): + print('\t==> Merging plugin-specific configuration', flush=True) + return merge(config, globalConfig) + else: + return globalConfig + +class OciDownloader: + def __init__(self, destination: str): + self._skopeo = shutil.which('skopeo') + if self._skopeo is None: + raise InstallException('skopeo executable not found in PATH') + + self.tmp_dir_obj = tempfile.TemporaryDirectory() + self.tmp_dir = self.tmp_dir_obj.name + self.image_to_tarball = {} + self.destination = destination + + def skopeo(self, command): + rv = subprocess.run([self._skopeo] + command, check=True, capture_output=True) + if rv.returncode != 0: + raise InstallException(f'Error while running skopeo command: {rv.stderr}') + return rv.stdout + + def get_plugin_tar(self, image: str) -> str: + if image not in self.image_to_tarball: + # run skopeo copy to copy the tar ball to the local filesystem + print(f'\t==> Copying image {image} to local filesystem', flush=True) + image_digest = hashlib.sha256(image.encode('utf-8'), usedforsecurity=False).hexdigest() + local_dir = os.path.join(self.tmp_dir, image_digest) + # replace oci:// prefix with docker:// + image_url = image.replace('oci://', 'docker://') + self.skopeo(['copy', image_url, f'dir:{local_dir}']) + manifest_path = os.path.join(local_dir, 'manifest.json') + manifest = json.load(open(manifest_path)) + # get the first layer of the image + layer = manifest['layers'][0]['digest'] + (_sha, filename) = layer.split(':') + local_path = os.path.join(local_dir, filename) + self.image_to_tarball[image] = local_path + + return self.image_to_tarball[image] + + def extract_plugin(self, tar_file: str, plugin_path: str) -> None: + with tarfile.open(tar_file, 'r:gz') as tar: # NOSONAR + # extract only the files in specified directory + filesToExtract = [] + for member in tar.getmembers(): + if not member.name.startswith(plugin_path): + continue + # zip bomb protection + if member.size > int(os.environ.get('MAX_ENTRY_SIZE', 20000000)): + raise InstallException('Zip bomb detected in ' + member.name) + + if member.islnk() or member.issym(): + realpath = os.path.realpath(os.path.join(plugin_path, *os.path.split(member.linkname))) + if not realpath.startswith(plugin_path): + print(f'\t==> WARNING: skipping file containing link outside of the archive: ' + member.name + ' -> ' + member.linkpath) + continue + + filesToExtract.append(member) + tar.extractall(os.path.abspath(self.destination), members=filesToExtract, filter='tar') + + + def download(self, package: str) -> str: + # split by ! to get the path in the image + (image, plugin_path) = package.split('!') + tar_file = self.get_plugin_tar(image) + plugin_directory = os.path.join(self.destination, plugin_path) + if os.path.exists(plugin_directory): + print('\t==> Removing previous plugin directory', plugin_directory, flush=True) + shutil.rmtree(plugin_directory, ignore_errors=True, onerror=None) + self.extract_plugin(tar_file=tar_file, plugin_path=plugin_path) + return plugin_path + + def digest(self, package: str) -> str: + (image, plugin_path) = package.split('!') + image_url = image.replace('oci://', 'docker://') + output = self.skopeo(['inspect', image_url]) + data = json.loads(output) + # OCI artifact digest field is defined as "hash method" ":" "hash" + digest = data['Digest'].split(':')[1] + return f"{digest}" + +def verify_package_integrity(plugin: dict, archive: str, working_directory: str) -> None: + package = plugin['package'] + if 'integrity' not in plugin: + raise InstallException(f'Package integrity for {package} is missing') + + integrity = plugin['integrity'] + if not isinstance(integrity, str): + raise InstallException(f'Package integrity for {package} must be a string') + + integrity = integrity.split('-') + if len(integrity) != 2: + raise InstallException(f'Package integrity for {package} must be a string of the form -') + + algorithm = integrity[0] + if algorithm not in RECOGNIZED_ALGORITHMS: + raise InstallException(f'{package}: Provided Package integrity algorithm {algorithm} is not supported, please use one of following algorithms {RECOGNIZED_ALGORITHMS} instead') + + hash_digest = integrity[1] + try: + base64.b64decode(hash_digest, validate=True) + except binascii.Error: + raise InstallException(f'{package}: Provided Package integrity hash {hash_digest} is not a valid base64 encoding') + + cat_process = subprocess.Popen(["cat", archive], stdout=subprocess.PIPE) + openssl_dgst_process = subprocess.Popen(["openssl", "dgst", "-" + algorithm, "-binary"], stdin=cat_process.stdout, stdout=subprocess.PIPE) + openssl_base64_process = subprocess.Popen(["openssl", "base64", "-A"], stdin=openssl_dgst_process.stdout, stdout=subprocess.PIPE) + + output, _ = openssl_base64_process.communicate() + if hash_digest != output.decode('utf-8').strip(): + raise InstallException(f'{package}: The hash of the downloaded package {output.decode("utf-8").strip()} does not match the provided integrity hash {hash_digest} provided in the configuration file') + +# Create the lock file, so that other instances of the script will wait for this one to finish +def create_lock(lock_file_path): + while True: + try: + with open(lock_file_path, 'x'): + print(f"======= Created lock file: {lock_file_path}") + return + except FileExistsError: + wait_for_lock_release(lock_file_path) + +# Remove the lock file +def remove_lock(lock_file_path): + os.remove(lock_file_path) + print(f"======= Removed lock file: {lock_file_path}") + +# Wait for the lock file to be released +def wait_for_lock_release(lock_file_path): + print(f"======= Waiting for lock release (file: {lock_file_path})...", flush=True) + while True: + if not os.path.exists(lock_file_path): + break + time.sleep(1) + print("======= Lock released.") + +def main(): + + dynamicPluginsRoot = sys.argv[1] if len(sys.argv) > 1 else "dynamic-plugins-root" + + lock_file_path = os.path.join(dynamicPluginsRoot, 'install-dynamic-plugins.lock') + atexit.register(remove_lock, lock_file_path) + signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0)) + create_lock(lock_file_path) + + maxEntrySize = int(os.environ.get('MAX_ENTRY_SIZE', 20000000)) + skipIntegrityCheck = os.environ.get("SKIP_INTEGRITY_CHECK", "").lower() == "true" + + dynamicPluginsFile = 'dynamic-plugins.yaml' + dynamicPluginsGlobalConfigFile = os.path.join(dynamicPluginsRoot, 'app-config.dynamic-plugins.yaml') + + # test if file dynamic-plugins.yaml exists + if not os.path.isfile(dynamicPluginsFile): + print(f"No {dynamicPluginsFile} file found. Skipping dynamic plugins installation.") + with open(dynamicPluginsGlobalConfigFile, 'w') as file: + file.write('') + file.close() + exit(0) + + globalConfig = { + 'dynamicPlugins': { + 'rootDirectory': 'dynamic-plugins-root' + } + } + + with open(dynamicPluginsFile, 'r') as file: + content = yaml.safe_load(file) + + if content == '' or content is None: + print(f"{dynamicPluginsFile} file is empty. Skipping dynamic plugins installation.") + with open(dynamicPluginsGlobalConfigFile, 'w') as file: + file.write('') + file.close() + exit(0) + + if not isinstance(content, dict): + raise InstallException(f"{dynamicPluginsFile} content must be a YAML object") + + allPlugins = {} + + if skipIntegrityCheck: + print(f"SKIP_INTEGRITY_CHECK has been set to {skipIntegrityCheck}, skipping integrity check of packages") + + if 'includes' in content: + includes = content['includes'] + else: + includes = [] + + if not isinstance(includes, list): + raise InstallException(f"content of the \'includes\' field must be a list in {dynamicPluginsFile}") + + for include in includes: + if not isinstance(include, str): + raise InstallException(f"content of the \'includes\' field must be a list of strings in {dynamicPluginsFile}") + + print('\n======= Including dynamic plugins from', include, flush=True) + + if not os.path.isfile(include): + raise InstallException(f"File {include} does not exist") + + with open(include, 'r') as file: + includeContent = yaml.safe_load(file) + + if not isinstance(includeContent, dict): + raise InstallException(f"{include} content must be a YAML object") + + includePlugins = includeContent['plugins'] + if not isinstance(includePlugins, list): + raise InstallException(f"content of the \'plugins\' field must be a list in {include}") + + for plugin in includePlugins: + allPlugins[plugin['package']] = plugin + + if 'plugins' in content: + plugins = content['plugins'] + else: + plugins = [] + + if not isinstance(plugins, list): + raise InstallException(f"content of the \'plugins\' field must be a list in {dynamicPluginsFile}") + + for plugin in plugins: + package = plugin['package'] + if not isinstance(package, str): + raise InstallException(f"content of the \'plugins.package\' field must be a string in {dynamicPluginsFile}") + + # if `package` already exists in `allPlugins`, then override its fields + if package not in allPlugins: + allPlugins[package] = plugin + continue + + # override the included plugins with fields in the main plugins list + print('\n======= Overriding dynamic plugin configuration', package, flush=True) + for key in plugin: + if key == 'package': + continue + allPlugins[package][key] = plugin[key] + + # add a hash for each plugin configuration to detect changes + for plugin in allPlugins.values(): + hash_dict = copy.deepcopy(plugin) + # remove elements that shouldn't be tracked for installation detection + hash_dict.pop('pluginConfig', None) + hash = hashlib.sha256(json.dumps(hash_dict, sort_keys=True).encode('utf-8')).hexdigest() + plugin['hash'] = hash + + # create a dict of all currently installed plugins in dynamicPluginsRoot + plugin_path_by_hash = {} + for dir_name in os.listdir(dynamicPluginsRoot): + dir_path = os.path.join(dynamicPluginsRoot, dir_name) + if os.path.isdir(dir_path): + hash_file_path = os.path.join(dir_path, 'dynamic-plugin-config.hash') + if os.path.isfile(hash_file_path): + with open(hash_file_path, 'r') as hash_file: + hash_value = hash_file.read().strip() + plugin_path_by_hash[hash_value] = dir_name + + oci_downloader = OciDownloader(dynamicPluginsRoot) + + # iterate through the list of plugins + for plugin in allPlugins.values(): + package = plugin['package'] + + if 'disabled' in plugin and plugin['disabled'] is True: + print('\n======= Skipping disabled dynamic plugin', package, flush=True) + continue + + # Stores the relative path of the plugin directory once downloaded + plugin_path = '' + if package.startswith('oci://'): + # The OCI downloader + try: + pull_policy = plugin.get('pullPolicy', PullPolicy.ALWAYS if ':latest!' in package else PullPolicy.IF_NOT_PRESENT) + + if plugin['hash'] in plugin_path_by_hash and pull_policy == PullPolicy.IF_NOT_PRESENT: + print('\n======= Skipping download of already installed dynamic plugin', package, flush=True) + plugin_path_by_hash.pop(plugin['hash']) + globalConfig = maybeMergeConfig(plugin.get('pluginConfig'), globalConfig) + continue + + if plugin['hash'] in plugin_path_by_hash and pull_policy == PullPolicy.ALWAYS: + digest_file_path = os.path.join(dynamicPluginsRoot, plugin_path_by_hash.pop(plugin['hash']), 'dynamic-plugin-image.hash') + local_image_digest = None + if os.path.isfile(digest_file_path): + with open(digest_file_path, 'r') as digest_file: + digest_value = digest_file.read().strip() + local_image_digest = digest_value + remote_image_digest = oci_downloader.digest(package) + if remote_image_digest == local_image_digest: + print('\n======= Skipping download of already installed dynamic plugin', package, flush=True) + globalConfig = maybeMergeConfig(plugin.get('pluginConfig'), globalConfig) + continue + else: + print('\n======= Installing dynamic plugin', package, flush=True) + + else: + print('\n======= Installing dynamic plugin', package, flush=True) + + plugin_path = oci_downloader.download(package) + digest_file_path = os.path.join(dynamicPluginsRoot, plugin_path, 'dynamic-plugin-image.hash') + with open(digest_file_path, 'w') as digest_file: + digest_file.write(oci_downloader.digest(package)) + # remove any duplicate hashes which can occur when only the version is updated + for key in [k for k, v in plugin_path_by_hash.items() if v == plugin_path]: + plugin_path_by_hash.pop(key) + except Exception as e: + raise InstallException(f"Error while adding OCI plugin {package} to downloader: {e}") + else: + # The NPM downloader + plugin_already_installed = False + pull_policy = plugin.get('pullPolicy', PullPolicy.IF_NOT_PRESENT) + + if plugin['hash'] in plugin_path_by_hash: + force_download = plugin.get('forceDownload', False) + if pull_policy == PullPolicy.ALWAYS or force_download: + print('\n======= Forcing download of already installed dynamic plugin', package, flush=True) + else: + print('\n======= Skipping download of already installed dynamic plugin', package, flush=True) + plugin_already_installed = True + # remove the hash from plugin_path_by_hash so that we can detect plugins that have been removed + plugin_path_by_hash.pop(plugin['hash']) + else: + print('\n======= Installing dynamic plugin', package, flush=True) + + if plugin_already_installed: + globalConfig = maybeMergeConfig(plugin.get('pluginConfig'), globalConfig) + continue + + package_is_local = package.startswith('./') + + # If package is not local, then integrity check is mandatory + if not package_is_local and not skipIntegrityCheck and not 'integrity' in plugin: + raise InstallException(f"No integrity hash provided for Package {package}") + + if package_is_local: + package = os.path.join(os.getcwd(), package[2:]) + + print('\t==> Grabbing package archive through `npm pack`', flush=True) + completed = subprocess.run(['npm', 'pack', package], capture_output=True, cwd=dynamicPluginsRoot) + if completed.returncode != 0: + raise InstallException(f'Error while installing plugin { package } with \'npm pack\' : ' + completed.stderr.decode('utf-8')) + + archive = os.path.join(dynamicPluginsRoot, completed.stdout.decode('utf-8').strip()) + + if not (package_is_local or skipIntegrityCheck): + print('\t==> Verifying package integrity', flush=True) + verify_package_integrity(plugin, archive, dynamicPluginsRoot) + + directory = archive.replace('.tgz', '') + directoryRealpath = os.path.realpath(directory) + plugin_path = os.path.basename(directoryRealpath) + + if os.path.exists(directory): + print('\t==> Removing previous plugin directory', directory, flush=True) + shutil.rmtree(directory, ignore_errors=True, onerror=None) + os.mkdir(directory) + + print('\t==> Extracting package archive', archive, flush=True) + file = tarfile.open(archive, 'r:gz') # NOSONAR + # extract the archive content but take care of zip bombs + for member in file.getmembers(): + if member.isreg(): + if not member.name.startswith('package/'): + raise InstallException("NPM package archive archive does not start with 'package/' as it should: " + member.name) + + if member.size > maxEntrySize: + raise InstallException('Zip bomb detected in ' + member.name) + + member.name = member.name.removeprefix('package/') + file.extract(member, path=directory, filter='tar') + elif member.isdir(): + print('\t\tSkipping directory entry', member.name, flush=True) + elif member.islnk() or member.issym(): + if not member.linkpath.startswith('package/'): + raise InstallException('NPM package archive contains a link outside of the archive: ' + member.name + ' -> ' + member.linkpath) + + member.name = member.name.removeprefix('package/') + member.linkpath = member.linkpath.removeprefix('package/') + + realpath = os.path.realpath(os.path.join(directory, *os.path.split(member.linkname))) + if not realpath.startswith(directoryRealpath): + raise InstallException('NPM package archive contains a link outside of the archive: ' + member.name + ' -> ' + member.linkpath) + + file.extract(member, path=directory, filter='tar') + else: + if member.type == tarfile.CHRTYPE: + type_str = "character device" + elif member.type == tarfile.BLKTYPE: + type_str = "block device" + elif member.type == tarfile.FIFOTYPE: + type_str = "FIFO" + else: + type_str = "unknown" + + raise InstallException('NPM package archive contains a non regular file: ' + member.name + ' - ' + type_str) + + file.close() + + print('\t==> Removing package archive', archive, flush=True) + os.remove(archive) + + # create a hash file in the plugin directory + hash = plugin['hash'] + hash_file_path = os.path.join(dynamicPluginsRoot, plugin_path, 'dynamic-plugin-config.hash') + with open(hash_file_path, 'w') as digest_file: + digest_file.write(hash) + + if 'pluginConfig' not in plugin: + print('\t==> Successfully installed dynamic plugin', package, flush=True) + continue + + # if some plugin configuration is defined, merge it with the global configuration + globalConfig = maybeMergeConfig(plugin.get('pluginConfig'), globalConfig) + + print('\t==> Successfully installed dynamic plugin', package, flush=True) + + yaml.safe_dump(globalConfig, open(dynamicPluginsGlobalConfigFile, 'w')) + + # remove plugins that have been removed from the configuration + for hash_value in plugin_path_by_hash: + plugin_directory = os.path.join(dynamicPluginsRoot, plugin_path_by_hash[hash_value]) + print('\n======= Removing previously installed dynamic plugin', plugin_path_by_hash[hash_value], flush=True) + shutil.rmtree(plugin_directory, ignore_errors=True, onerror=None) + +main() diff --git a/scripts/check_dynamic_plugins.sh b/scripts/check_dynamic_plugins.sh new file mode 100755 index 0000000..0beeb7a --- /dev/null +++ b/scripts/check_dynamic_plugins.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +# Verifica e instala os dynamic plugins definidos em dynamic-plugins.default.yaml +python3 scripts/check_dynamic_plugins.py \ No newline at end of file