Plugins: Change managed plugins installation call (#77120)

This commit is contained in:
Hugo Kiyodi Oshiro
2023-11-10 12:28:36 +01:00
committed by GitHub
parent 6097ff255c
commit e754c5a6c6
12 changed files with 95 additions and 33 deletions

View File

@@ -392,7 +392,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Any("/plugin-proxy/:pluginId/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginIDScope)), hs.ProxyPluginRequest) apiRoute.Any("/plugin-proxy/:pluginId/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginIDScope)), hs.ProxyPluginRequest)
apiRoute.Any("/plugin-proxy/:pluginId", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginIDScope)), hs.ProxyPluginRequest) apiRoute.Any("/plugin-proxy/:pluginId", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginIDScope)), hs.ProxyPluginRequest)
if hs.Cfg.PluginAdminEnabled && !hs.Cfg.PluginAdminExternalManageEnabled { if hs.Cfg.PluginAdminEnabled && (hs.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagManagedPluginsInstall) || !hs.Cfg.PluginAdminExternalManageEnabled) {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Post("/:pluginId/install", authorize(ac.EvalPermission(pluginaccesscontrol.ActionInstall)), routing.Wrap(hs.InstallPlugin)) pluginRoute.Post("/:pluginId/install", authorize(ac.EvalPermission(pluginaccesscontrol.ActionInstall)), routing.Wrap(hs.InstallPlugin))
pluginRoute.Post("/:pluginId/uninstall", authorize(ac.EvalPermission(pluginaccesscontrol.ActionInstall)), routing.Wrap(hs.UninstallPlugin)) pluginRoute.Post("/:pluginId/uninstall", authorize(ac.EvalPermission(pluginaccesscontrol.ActionInstall)), routing.Wrap(hs.UninstallPlugin))

View File

@@ -67,6 +67,8 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
hs.Cfg = &setting.Cfg{ hs.Cfg = &setting.Cfg{
PluginAdminEnabled: tc.pluginAdminEnabled, PluginAdminEnabled: tc.pluginAdminEnabled,
PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled} PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled}
hs.Cfg.IsFeatureToggleEnabled = func(_ string) bool { return false }
hs.orgService = &orgtest.FakeOrgService{ExpectedOrg: &org.Org{}} hs.orgService = &orgtest.FakeOrgService{ExpectedOrg: &org.Org{}}
hs.pluginInstaller = NewFakePluginInstaller() hs.pluginInstaller = NewFakePluginInstaller()
hs.pluginFileStore = &fakes.FakePluginFileStore{} hs.pluginFileStore = &fakes.FakePluginFileStore{}

View File

@@ -3,6 +3,7 @@ package pluginaccesscontrol
import ( import (
ac "github.com/grafana/grafana/pkg/services/accesscontrol" ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
@@ -67,7 +68,8 @@ func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg) error {
Grants: []string{ac.RoleGrafanaAdmin}, Grants: []string{ac.RoleGrafanaAdmin},
} }
if !cfg.PluginAdminEnabled || cfg.PluginAdminExternalManageEnabled { if !cfg.PluginAdminEnabled ||
(cfg.PluginAdminExternalManageEnabled && !cfg.IsFeatureToggleEnabled(featuremgmt.FlagManagedPluginsInstall)) {
PluginsMaintainer.Grants = []string{} PluginsMaintainer.Grants = []string{}
} }

View File

@@ -2,9 +2,9 @@ import { PluginError, PluginMeta, renderMarkdown } from '@grafana/data';
import { getBackendSrv, isFetchError } from '@grafana/runtime'; import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { API_ROOT, GCOM_API_ROOT } from './constants'; import { API_ROOT, GCOM_API_ROOT, INSTANCE_API_ROOT } from './constants';
import { isLocalPluginVisibleByConfig, isRemotePluginVisibleByConfig } from './helpers'; import { isLocalPluginVisibleByConfig, isRemotePluginVisibleByConfig } from './helpers';
import { LocalPlugin, RemotePlugin, CatalogPluginDetails, Version, PluginVersion } from './types'; import { LocalPlugin, RemotePlugin, CatalogPluginDetails, Version, PluginVersion, InstancePlugin } from './types';
export async function getPluginDetails(id: string): Promise<CatalogPluginDetails> { export async function getPluginDetails(id: string): Promise<CatalogPluginDetails> {
const remote = await getRemotePlugin(id); const remote = await getRemotePlugin(id);
@@ -105,6 +105,14 @@ export async function getLocalPlugins(): Promise<LocalPlugin[]> {
return localPlugins.filter(isLocalPluginVisibleByConfig); return localPlugins.filter(isLocalPluginVisibleByConfig);
} }
export async function getInstancePlugins(): Promise<InstancePlugin[]> {
const { items: instancePlugins }: { items: InstancePlugin[] } = await getBackendSrv().get(
`${INSTANCE_API_ROOT}/plugins`
);
return instancePlugins;
}
export async function installPlugin(id: string) { export async function installPlugin(id: string) {
// This will install the latest compatible version based on the logic // This will install the latest compatible version based on the logic
// on the backend. // on the backend.

View File

@@ -5,6 +5,7 @@ import { AppEvents } from '@grafana/data';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { Button, HorizontalGroup, ConfirmModal } from '@grafana/ui'; import { Button, HorizontalGroup, ConfirmModal } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import configCore from 'app/core/config';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { removePluginFromNavTree } from 'app/core/reducers/navBarTree'; import { removePluginFromNavTree } from 'app/core/reducers/navBarTree';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
@@ -65,13 +66,18 @@ export function InstallControlsButton({
const onInstall = async () => { const onInstall = async () => {
trackPluginInstalled(trackingProps); trackPluginInstalled(trackingProps);
const result = await install(plugin.id, latestCompatibleVersion?.version); const result = await install(plugin.id, latestCompatibleVersion?.version);
// refresh the store to have the new installed plugin
await fetchDetails(plugin.id);
if (!errorInstalling && !('error' in result)) { if (!errorInstalling && !('error' in result)) {
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]); let successMessage = `Installed ${plugin.name}`;
if (config.pluginAdminExternalManageEnabled && configCore.featureToggles.managedPluginsInstall) {
successMessage = 'Install requested, this may take a few minutes.';
}
appEvents.emit(AppEvents.alertSuccess, [successMessage]);
if (plugin.type === 'app') { if (plugin.type === 'app') {
setNeedReload?.(true); setNeedReload?.(true);
} }
await fetchDetails(plugin.id);
} }
}; };

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { HorizontalGroup, Icon, useStyles2, VerticalGroup } from '@grafana/ui'; import { HorizontalGroup, Icon, useStyles2, VerticalGroup } from '@grafana/ui';
import configCore from 'app/core/config';
import { GetStartedWithPlugin } from '../components/GetStartedWithPlugin'; import { GetStartedWithPlugin } from '../components/GetStartedWithPlugin';
import { InstallControlsButton } from '../components/InstallControls'; import { InstallControlsButton } from '../components/InstallControls';
@@ -40,7 +41,7 @@ export const PluginActions = ({ plugin }: Props) => {
<HorizontalGroup> <HorizontalGroup>
{!isInstallControlsDisabled && ( {!isInstallControlsDisabled && (
<> <>
{isExternallyManaged && !hasInstallWarning ? ( {isExternallyManaged && !hasInstallWarning && !configCore.featureToggles.managedPluginsInstall ? (
<ExternallyManagedButton <ExternallyManagedButton
pluginId={plugin.id} pluginId={plugin.id}
pluginStatus={pluginStatus} pluginStatus={pluginStatus}

View File

@@ -1,4 +1,5 @@
export const API_ROOT = '/api/plugins'; export const API_ROOT = '/api/plugins';
export const INSTANCE_API_ROOT = '/api/instance';
export const GCOM_API_ROOT = '/api/gnet'; export const GCOM_API_ROOT = '/api/gnet';
// Used for prefixing the Redux actions // Used for prefixing the Redux actions

View File

@@ -37,7 +37,7 @@ describe('Plugins/Helpers', () => {
]; ];
test('adds all available plugins only once', () => { test('adds all available plugins only once', () => {
const merged = mergeLocalsAndRemotes(localPlugins, remotePlugins); const merged = mergeLocalsAndRemotes({ local: localPlugins, remote: remotePlugins });
const mergedIds = merged.map(({ id }) => id); const mergedIds = merged.map(({ id }) => id);
expect(merged.length).toBe(4); expect(merged.length).toBe(4);
@@ -48,7 +48,7 @@ describe('Plugins/Helpers', () => {
}); });
test('merges all plugins with their counterpart (if available)', () => { test('merges all plugins with their counterpart (if available)', () => {
const merged = mergeLocalsAndRemotes(localPlugins, remotePlugins); const merged = mergeLocalsAndRemotes({ local: localPlugins, remote: remotePlugins });
const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId); const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId);
// Both local & remote counterparts // Both local & remote counterparts
@@ -67,10 +67,10 @@ describe('Plugins/Helpers', () => {
}); });
test('skips deprecated plugins unless they have a local - installed - counterpart', () => { test('skips deprecated plugins unless they have a local - installed - counterpart', () => {
const merged = mergeLocalsAndRemotes(localPlugins, [ const merged = mergeLocalsAndRemotes({
...remotePlugins, local: localPlugins,
getRemotePluginMock({ slug: 'plugin-5', status: RemotePluginStatus.Deprecated }), remote: [...remotePlugins, getRemotePluginMock({ slug: 'plugin-5', status: RemotePluginStatus.Deprecated })],
]); });
const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId); const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId);
expect(merged).toHaveLength(4); expect(merged).toHaveLength(4);
@@ -78,10 +78,10 @@ describe('Plugins/Helpers', () => {
}); });
test('keeps deprecated plugins in case they have a local counterpart', () => { test('keeps deprecated plugins in case they have a local counterpart', () => {
const merged = mergeLocalsAndRemotes( const merged = mergeLocalsAndRemotes({
[...localPlugins, getLocalPluginMock({ id: 'plugin-5' })], local: [...localPlugins, getLocalPluginMock({ id: 'plugin-5' })],
[...remotePlugins, getRemotePluginMock({ slug: 'plugin-5', status: RemotePluginStatus.Deprecated })] remote: [...remotePlugins, getRemotePluginMock({ slug: 'plugin-5', status: RemotePluginStatus.Deprecated })],
); });
const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId); const findMerged = (mergedId: string) => merged.find(({ id }) => id === mergedId);
expect(merged).toHaveLength(5); expect(merged).toHaveLength(5);
@@ -132,6 +132,7 @@ describe('Plugins/Helpers', () => {
signature: 'valid', signature: 'valid',
type: 'app', type: 'app',
updatedAt: '2021-05-18T14:53:01.000Z', updatedAt: '2021-05-18T14:53:01.000Z',
isFullyInstalled: false,
}); });
}); });
@@ -210,6 +211,7 @@ describe('Plugins/Helpers', () => {
type: 'app', type: 'app',
updatedAt: '2021-08-25', updatedAt: '2021-08-25',
installedVersion: '4.2.2', installedVersion: '4.2.2',
isFullyInstalled: true,
}); });
}); });
@@ -259,6 +261,7 @@ describe('Plugins/Helpers', () => {
type: 'app', type: 'app',
updatedAt: '2021-05-18T14:53:01.000Z', updatedAt: '2021-05-18T14:53:01.000Z',
installedVersion: '4.2.2', installedVersion: '4.2.2',
isFullyInstalled: true,
}); });
}); });

View File

@@ -1,20 +1,31 @@
import { PluginSignatureStatus, dateTimeParse, PluginError, PluginType, PluginErrorCode } from '@grafana/data'; import { PluginSignatureStatus, dateTimeParse, PluginError, PluginType, PluginErrorCode } from '@grafana/data';
import { config, featureEnabled } from '@grafana/runtime'; import { config, featureEnabled } from '@grafana/runtime';
import { Settings } from 'app/core/config'; import configCore, { Settings } from 'app/core/config';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { getBackendSrv } from 'app/core/services/backend_srv'; import { getBackendSrv } from 'app/core/services/backend_srv';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { CatalogPlugin, LocalPlugin, RemotePlugin, RemotePluginStatus, Version } from './types'; import { CatalogPlugin, InstancePlugin, LocalPlugin, RemotePlugin, RemotePluginStatus, Version } from './types';
export function mergeLocalsAndRemotes( export function mergeLocalsAndRemotes({
local: LocalPlugin[] = [], local = [],
remote: RemotePlugin[] = [], remote = [],
errors?: PluginError[] instance = [],
): CatalogPlugin[] { pluginErrors: errors,
}: {
local: LocalPlugin[];
remote?: RemotePlugin[];
instance?: InstancePlugin[];
pluginErrors?: PluginError[];
}): CatalogPlugin[] {
const catalogPlugins: CatalogPlugin[] = []; const catalogPlugins: CatalogPlugin[] = [];
const errorByPluginId = groupErrorsByPluginId(errors); const errorByPluginId = groupErrorsByPluginId(errors);
const instancesSet = instance.reduce((set, instancePlugin) => {
set.add(instancePlugin.pluginSlug);
return set;
}, new Set<string>());
// add locals // add locals
local.forEach((localPlugin) => { local.forEach((localPlugin) => {
const remoteCounterpart = remote.find((r) => r.slug === localPlugin.id); const remoteCounterpart = remote.find((r) => r.slug === localPlugin.id);
@@ -32,7 +43,15 @@ export function mergeLocalsAndRemotes(
const shouldSkip = remotePlugin.status === RemotePluginStatus.Deprecated && !localCounterpart; // We are only listing deprecated plugins in case they are installed. const shouldSkip = remotePlugin.status === RemotePluginStatus.Deprecated && !localCounterpart; // We are only listing deprecated plugins in case they are installed.
if (!shouldSkip) { if (!shouldSkip) {
catalogPlugins.push(mergeLocalAndRemote(localCounterpart, remotePlugin, error)); const catalogPlugin = mergeLocalAndRemote(localCounterpart, remotePlugin, error);
// for managed instances, check if plugin is installed, but not yet present in the current instance
if (configCore.featureToggles.managedPluginsInstall && config.pluginAdminExternalManageEnabled) {
catalogPlugin.isFullyInstalled = instancesSet.has(remotePlugin.slug) && catalogPlugin.isInstalled;
catalogPlugin.isInstalled = instancesSet.has(remotePlugin.slug) || catalogPlugin.isInstalled;
}
catalogPlugins.push(catalogPlugin);
} }
}); });
@@ -95,6 +114,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
type: typeCode, type: typeCode,
error: error?.errorCode, error: error?.errorCode,
angularDetected, angularDetected,
isFullyInstalled: isDisabled,
}; };
} }
@@ -140,6 +160,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
error: error?.errorCode, error: error?.errorCode,
accessControl: accessControl, accessControl: accessControl,
angularDetected, angularDetected,
isFullyInstalled: true,
}; };
} }
@@ -196,6 +217,7 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
// Only local plugins have access control metadata // Only local plugins have access control metadata
accessControl: local?.accessControl, accessControl: local?.accessControl,
angularDetected: local?.angularDetected || remote?.angularDetected, angularDetected: local?.angularDetected || remote?.angularDetected,
isFullyInstalled: Boolean(local) || isDisabled,
}; };
} }

View File

@@ -9,7 +9,7 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => {
return null; return null;
} }
if (plugin.isInstalled && !plugin.isDisabled) { if (plugin.isFullyInstalled && !plugin.isDisabled) {
return loadPlugin(plugin.id); return loadPlugin(plugin.id);
} }
return null; return null;

View File

@@ -1,8 +1,9 @@
import { createAction, createAsyncThunk, Update } from '@reduxjs/toolkit'; import { createAction, createAsyncThunk, Update } from '@reduxjs/toolkit';
import { from, forkJoin, timeout, lastValueFrom, catchError, throwError } from 'rxjs'; import { from, forkJoin, timeout, lastValueFrom, catchError, throwError, of } from 'rxjs';
import { PanelPlugin, PluginError } from '@grafana/data'; import { PanelPlugin, PluginError } from '@grafana/data';
import { getBackendSrv, isFetchError } from '@grafana/runtime'; import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import configCore from 'app/core/config';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { StoreState, ThunkResult } from 'app/types'; import { StoreState, ThunkResult } from 'app/types';
@@ -14,10 +15,11 @@ import {
getPluginDetails, getPluginDetails,
installPlugin, installPlugin,
uninstallPlugin, uninstallPlugin,
getInstancePlugins,
} from '../api'; } from '../api';
import { STATE_PREFIX } from '../constants'; import { STATE_PREFIX } from '../constants';
import { mapLocalToCatalog, mergeLocalsAndRemotes, updatePanels } from '../helpers'; import { mapLocalToCatalog, mergeLocalsAndRemotes, updatePanels } from '../helpers';
import { CatalogPlugin, RemotePlugin, LocalPlugin } from '../types'; import { CatalogPlugin, RemotePlugin, LocalPlugin, InstancePlugin } from '../types';
// Fetches // Fetches
export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => { export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => {
@@ -27,11 +29,16 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t
const local$ = from(getLocalPlugins()); const local$ = from(getLocalPlugins());
const remote$ = from(getRemotePlugins()); const remote$ = from(getRemotePlugins());
const instance$ =
config.pluginAdminExternalManageEnabled && configCore.featureToggles.managedPluginsInstall
? from(getInstancePlugins())
: of(undefined);
const pluginErrors$ = from(getPluginErrors()); const pluginErrors$ = from(getPluginErrors());
forkJoin({ forkJoin({
local: local$, local: local$,
remote: remote$, remote: remote$,
instance: instance$,
pluginErrors: pluginErrors$, pluginErrors: pluginErrors$,
}) })
.pipe( .pipe(
@@ -55,9 +62,10 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t
if (remote.length > 0) { if (remote.length > 0) {
const local = await lastValueFrom(local$); const local = await lastValueFrom(local$);
const instance = await lastValueFrom(instance$);
const pluginErrors = await lastValueFrom(pluginErrors$); const pluginErrors = await lastValueFrom(pluginErrors$);
thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes(local, remote, pluginErrors))); thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes({ local, remote, instance, pluginErrors })));
} }
}); });
@@ -69,21 +77,23 @@ export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, t
({ ({
local, local,
remote, remote,
instance,
pluginErrors, pluginErrors,
}: { }: {
local: LocalPlugin[]; local: LocalPlugin[];
remote?: RemotePlugin[]; remote?: RemotePlugin[];
instance?: InstancePlugin[];
pluginErrors: PluginError[]; pluginErrors: PluginError[];
}) => { }) => {
thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchLocal/fulfilled` }); thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchLocal/fulfilled` });
// Both local and remote plugins are loaded // Both local and remote plugins are loaded
if (local && remote) { if (local && remote) {
thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes(local, remote, pluginErrors))); thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes({ local, remote, instance, pluginErrors })));
// Only remote plugins are loaded (remote timed out) // Only remote plugins are loaded (remote timed out)
} else if (local) { } else if (local) {
thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes(local, [], pluginErrors))); thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes({ local, pluginErrors })));
} }
} }
); );

View File

@@ -59,6 +59,9 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
details?: CatalogPluginDetails; details?: CatalogPluginDetails;
error?: PluginErrorCode; error?: PluginErrorCode;
angularDetected?: boolean; angularDetected?: boolean;
// instance plugins may not be fully installed, which means a new instance
// running the plugin didn't started yet
isFullyInstalled?: boolean;
} }
export interface CatalogPluginDetails { export interface CatalogPluginDetails {
@@ -294,3 +297,7 @@ export type PluginVersion = {
isCompatible: boolean; isCompatible: boolean;
grafanaDependency: string | null; grafanaDependency: string | null;
}; };
export type InstancePlugin = {
pluginSlug: string;
};