This is an automated email from the ASF dual-hosted git repository. jbertram pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/activemq-artemis-console.git
commit 904ca3887a4ae647183461d555dc27511902ac28 Author: Grzegorz Grzybek <[email protected]> AuthorDate: Tue Nov 18 11:42:00 2025 +0100 ARTEMIS-5743: Handle inaccessible attributes better, when no RBAC metadata is available --- .../src/artemis-service.test.ts | 11 +- .../artemis-console-plugin/src/artemis-service.ts | 206 ++++++++++++++++++--- .../artemis-console-plugin/src/status/Status.tsx | 146 +++++++++------ 3 files changed, 282 insertions(+), 81 deletions(-) diff --git a/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.test.ts b/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.test.ts index 7ff962e..d043919 100644 --- a/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.test.ts +++ b/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ import { beforeAll, describe, expect, test } from "@jest/globals" -import { artemisService } from "./artemis-service"; +import { artemisService, parseMBeanName } from "./artemis-service"; import { SortDirection } from './table/ArtemisTable' import { userService } from '@hawtio/react' @@ -41,4 +41,13 @@ describe("Artemis Service basic tests", () => { await expect(addresses).resolves.toContain("DLQ"); }) + test("Splitting ObjectNames", () => { + const mbean = "org.apache.activemq.artemis:broker=\"0.0.0.0:61616\",component=acceptors,filter=\"x,y,z=a\",name=amqp" + const parsed = parseMBeanName(mbean) + expect(parsed.domain).toEqual("org.apache.activemq.artemis") + expect(parsed.properties["broker"]).toEqual("\"0.0.0.0:61616\"") + expect(parsed.properties["filter"]).toEqual("\"x,y,z=a\"") + expect(parsed.properties["name"]).toEqual("amqp") + }) + }) diff --git a/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.ts b/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.ts index c25e5b1..aee3de3 100644 --- a/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.ts +++ b/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/artemis-service.ts @@ -15,23 +15,40 @@ * limitations under the License. */ import { ActiveSort, Filter } from './table/ArtemisTable' -import { jolokiaService, MBeanNode } from '@hawtio/react' +import { eventService, jolokiaService, MBeanNode, workspace } from '@hawtio/react' import { createAddressObjectName, createQueueObjectName } from './util/jmx' import { log } from './globals' import { Message } from './messages/MessageView' import { configManager } from './config-manager' +export type BrokerState = { + // loading attempted? + loaded: boolean + // permission granted? + accessible: boolean + // possible error message + message?: string + // if everything is configured correctly, this is fully populated broker information + info?: BrokerInfo +} +// Value returned by Jolokia, when RBAC prevents access +// see: org.jolokia.server.core.service.serializer.ValueFaultHandler.IGNORING_VALUE_FAULT_HANDLER +export type InaccessibleValue = { + ".error": boolean + error?: string + error_type?: string +} export type BrokerInfo = { name: string nodeID: string objectName: string - version: string - started: string - uptime: string + version: string | InaccessibleValue + started: string | InaccessibleValue + uptime: string | InaccessibleValue globalMaxSizeMB: number addressMemoryUsage: number addressMemoryUsed: number - haPolicy: string + haPolicy: string | InaccessibleValue networkTopology: BrokerNetworkTopology } @@ -179,28 +196,55 @@ class ArtemisService { const brokerObjectName = await this.brokerObjectName; const response = await jolokiaService.readAttribute(brokerObjectName, "Name"); if (response) { + if (typeof response === "object" && ".error" in response) { + return "" + } return response as string; } return null; } - async getBrokerInfo(): Promise<BrokerInfo | null> { - return new Promise<BrokerInfo | null>(async (resolve, reject) => { + async getBrokerInfo(): Promise<BrokerState> { + return new Promise<BrokerState>(async (resolve, _reject) => { const brokerObjectName = await this.brokerObjectName; if ("" === brokerObjectName) { - resolve(null) + resolve({ loaded: true, accessible: false, message: "No Broker ObjectName available" }) return } - const response = await jolokiaService.readAttributes(brokerObjectName).catch(e => null); + const brokerMBean = await findMBeanInfo(brokerObjectName) + if (!brokerMBean) { + resolve({ loaded: true, accessible: false, message: `No Broker MBean available for name ${brokerObjectName}` }) + return + } + // `canRead` flag is added by jolokia-integration 0.7.1+ and ideally it should be checked _before_ + // getting an attribute (just as `canInvoke` should be checked before executing an operation), but + // RBAC data may not be complete, so it's still good to be prepared (and add relevant `.catch(error => {...})` + let permitted = false + const nameInfo = brokerMBean.mbean?.attr?.["Name"] + if (nameInfo && "canRead" in nameInfo) { + permitted = nameInfo["canRead"] as boolean + } else if ("canInvoke" in brokerMBean.mbean!) { + permitted = brokerMBean.mbean?.["canInvoke"] as boolean + } + if (!permitted) { + resolve({ loaded: true, accessible: false, message: `Access not granted to broker ${brokerMBean.objectName}` }) + return + } + + const response = await jolokiaService.readAttributes(brokerObjectName).catch(e => { + // this is the best (as of Nov 2025) way to handle problems when fetching attributes with RBAC enabled + eventService.notify({type: 'warning', message: jolokiaService.errorMessage(e) }) + return null + }); if (response) { const name = response.Name as string; const nodeID = response.NodeID as string; - const version = response.Version as string; - const started = "" + response.Started as string; + const version = response.Version as string | InaccessibleValue; + const started = response.Started as string | InaccessibleValue; const globalMaxSize = response.GlobalMaxSize as number; const addressMemoryUsage = response.AddressMemoryUsage as number; - const uptime = response.Uptime as string; - const haPolicy = response.HAPolicy as string; + const uptime = response.Uptime as string | InaccessibleValue; + const haPolicy = response.HAPolicy as string | InaccessibleValue; const globalMaxSizeMB = globalMaxSize / 1048576; let used = 0; let addressMemoryUsageMB = 0; @@ -208,9 +252,13 @@ class ArtemisService { addressMemoryUsageMB = addressMemoryUsage / 1048576; used = addressMemoryUsageMB / globalMaxSizeMB * 100 } - const topology = await jolokiaService.execute(brokerObjectName, LIST_NETWORK_TOPOLOGY_SIG) as string; + const topology = await jolokiaService.execute(brokerObjectName, LIST_NETWORK_TOPOLOGY_SIG).catch(e => { + eventService.notify({type: 'warning', message: jolokiaService.errorMessage(e) }) + return "{}" + }) as string; const brokerInfo: BrokerInfo = { - name: name, objectName: brokerObjectName, + name: name, + objectName: brokerObjectName, nodeID: nodeID, version: version, started: started, @@ -221,18 +269,26 @@ class ArtemisService { haPolicy: haPolicy, networkTopology: new BrokerNetworkTopology(JSON.parse(topology)) }; - resolve(brokerInfo); + resolve({ loaded: true, accessible: true, info: brokerInfo }); + return } - resolve(null) + resolve({ loaded: true, accessible: false, message: `No information available for broker ${brokerMBean.objectName}` }) }); } - async createBrokerTopology(maxAddresses: number, addressFilter: string): Promise<BrokerTopology> { - return new Promise<BrokerTopology>(async (resolve, reject) => { + async createBrokerTopology(maxAddresses: number, addressFilter: string): Promise<BrokerTopology | null> { + return new Promise<BrokerTopology | null>(async (resolve, reject) => { try { - const brokerInfo = await this.getBrokerInfo(); + const state = await this.getBrokerInfo(); + if (!state || !state.info) { + resolve(null) + } + const brokerInfo = state.info const brokerObjectName = await this.brokerObjectName; - const topology = await jolokiaService.execute(brokerObjectName, LIST_NETWORK_TOPOLOGY_SIG) as string; + const topology = await jolokiaService.execute(brokerObjectName, LIST_NETWORK_TOPOLOGY_SIG).catch(error => { + eventService.notify({ type: "warning", message: jolokiaService.errorMessage(error) }) + return "[]" + }) as string; brokerInfo!.networkTopology = new BrokerNetworkTopology(JSON.parse(topology)); const brokerTopology: BrokerTopology = { broker: brokerInfo!, @@ -242,7 +298,10 @@ class ArtemisService { const max: number = maxAddresses < addresses.length ? maxAddresses: addresses.length; addresses = addresses.slice(0, max); for (const address of addresses) { - const queuesJson: string = await this.getQueuesForAddress(address); + const queuesJson: string = await this.getQueuesForAddress(address).catch(error => { + eventService.notify({ type: "warning", message: jolokiaService.errorMessage(error) }) + return JSON.stringify({ data: [], count: 0 }) + }) const queues: Queue[] = JSON.parse(queuesJson).data; brokerTopology.addresses.push({ name: address, @@ -273,7 +332,12 @@ class ArtemisService { .catch((e) => { reject(e) }) as Acceptor; - acceptors.acceptors.push(acceptor); + const validation = isValid(acceptor) + if (validation.valid) { + acceptors.acceptors.push(acceptor); + } else { + eventService.notify({ type: "warning", message: `Access not granted to ${search[key]}` }) + } } resolve(acceptors); } @@ -296,7 +360,12 @@ class ArtemisService { .catch((e) => { reject(e) }) as ClusterConnection; - clusterConnections.clusterConnections.push(clusterConnection); + const validation = isValid(clusterConnection) + if (validation.valid) { + clusterConnections.clusterConnections.push(clusterConnection); + } else { + eventService.notify({ type: "warning", message: `Access not granted to ${search[key]}` }) + } } resolve(clusterConnections); } @@ -691,4 +760,93 @@ class ArtemisService { } } +export function parseMBeanName(name: string): { domain: string, properties: Record<string, string> } { + const colon = name.indexOf(":") + if (colon === -1) { + throw new Error("Illegal ObjectName") + } + + const domain = name.substring(0, colon) + const propsString = name.substring(colon + 1) + + let i = 0 + const len = propsString.length + const props: Record<string, string> = {} + + while (i < len) { + let key = "" + while (i < len && propsString[i] !== "=") { + key += propsString[i++] + } + + // skip '=' + i++ + + let value = "" + if (propsString[i] === '"') { + // quoted value - only double quote + i++ + while (i < len) { + const ch = propsString[i++] + if (ch === '"') { + break + } + value += ch + } + } else { + // unquoted value can be empty + while (i < len && propsString[i] !== ",") { + value += propsString[i++] + } + } + + props[key.trim()] = value.trim() + + if (propsString[i] === ",") { + i++ + } + } + + return { domain, properties: props }; +} + +export async function findMBeanInfo(brokerObjectName: string): Promise<MBeanNode | null> { + const tree = await workspace.getTree() + const parsed = parseMBeanName(brokerObjectName) + + const matching = tree.findMBeans(parsed.domain, parsed.properties) + if (!matching) { + return null + } + if (matching.length == 1) { + return matching[0] + } else { + const more = matching.filter(node => node.objectName === brokerObjectName) + return more.length > 0 ? more[0] : null + } +} + +/** + * Jolokia's `getAttributes()` may return an object, where each field is an error value (when RBAC is enabled + * for example). In this case, the value has special `.error` field. This function checks validity of an object + * or value. + * @param value + */ +export function isValid(value: any): { valid: boolean, message: string | null } { + if (typeof value === "object") { + if (".error" in value) { + // the value itself is an error value + return { valid: false, message: jolokiaService.errorMessage(value) } + } + for (const k in value) { + const v = value[k] + if (typeof v === "object" && ".error" in v) { + // fail fast with an error related to first field of the wrapping object + return { valid: false, message: jolokiaService.errorMessage(v) } + } + } + } + return { valid: true, message: null } +} + export const artemisService = new ArtemisService() \ No newline at end of file diff --git a/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/status/Status.tsx b/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/status/Status.tsx index a250f98..3578bc3 100644 --- a/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/status/Status.tsx +++ b/artemis-console-extension/artemis-extension/packages/artemis-console-plugin/src/status/Status.tsx @@ -16,6 +16,7 @@ */ import { ChartDonutUtilization } from "@patternfly/react-charts" import { + Alert, Card, CardBody, CardTitle, @@ -34,20 +35,25 @@ import { Modal, ModalVariant, DropdownList, + MenuToggle, MenuToggleElement, - MenuToggle + PageSection, + Spinner, + Tooltip } from "@patternfly/react-core" import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon' import { ExclamationCircleIcon } from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon' import { OkIcon } from '@patternfly/react-icons/dist/esm/icons/ok-icon' -import { Attributes, eventService, Operations } from '@hawtio/react'; -import React, { useContext, useEffect, useState } from "react"; -import { Acceptors, artemisService, BrokerInfo, ClusterConnections } from "../artemis-service"; +import { Attributes, eventService, jolokiaService, Operations } from '@hawtio/react'; +import React, { ReactNode, useContext, useEffect, useState } from "react"; +import { Acceptors, artemisService, BrokerInfo, BrokerState, ClusterConnections } from "../artemis-service"; import { ArtemisContext } from "../context"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; +import { LockedIcon } from '@patternfly/react-icons' export const Status: React.FunctionComponent = () => { + const [brokerState, setBrokerState] = useState<BrokerState>({ loaded: false, accessible: false, message: "Loading..." }) const [brokerInfo, setBrokerInfo] = useState<BrokerInfo>() const [acceptors, setAcceptors] = useState<Acceptors>(); const [clusterConnections, setClusterConnections] = useState<ClusterConnections>() @@ -59,13 +65,14 @@ export const Status: React.FunctionComponent = () => { const getBrokerInfo = async () => { artemisService.getBrokerInfo() .then((brokerInfo) => { - setBrokerInfo(brokerInfo ?? undefined) + if (brokerInfo.info) { + setBrokerInfo(brokerInfo.info) + } + setBrokerState({ loaded: brokerInfo.loaded, accessible: brokerInfo.accessible, message: brokerInfo.message }) }) - .catch((error: string | { [key: string]: any }) => { - eventService.notify({ - type: 'warning', - message: typeof error === 'object' ? ('error' in error ? error.error : JSON.stringify(error)) : error, - }) + .catch((error) => { + setBrokerState({ loaded: false, accessible: false, message: `Error loading broker: ${jolokiaService.errorMessage(error)}` }) + eventService.notify({type: 'warning', message: jolokiaService.errorMessage(error) }) }); } @@ -74,11 +81,8 @@ export const Status: React.FunctionComponent = () => { .then((acceptors) => { setAcceptors(acceptors) }) - .catch((error: string | { [key: string]: any }) => { - eventService.notify({ - type: 'warning', - message: typeof error === 'object' ? ('error' in error ? error.error : JSON.stringify(error)) : error, - }) + .catch((error) => { + eventService.notify({type: 'warning', message: jolokiaService.errorMessage(error) }) }); } @@ -87,11 +91,8 @@ export const Status: React.FunctionComponent = () => { .then((clusterConnections) => { setClusterConnections(clusterConnections) }) - .catch((error: string | { [key: string]: any }) => { - eventService.notify({ - type: 'warning', - message: typeof error === 'object' ? ('error' in error ? error.error : JSON.stringify(error)) : error, - }) + .catch((error) => { + eventService.notify({type: 'warning', message: jolokiaService.errorMessage(error) }) }); } @@ -118,7 +119,7 @@ export const Status: React.FunctionComponent = () => { setIsBrokerInfoOpen(!isBrokerInfoOpen); }; - const openAttrubutes = async () => { + const openAttributes = async () => { const brokerObjectName = await artemisService.getBrokerObjectName(); findAndSelectNode(brokerObjectName, ""); setShowAttributesDialog(true); @@ -144,7 +145,7 @@ export const Status: React.FunctionComponent = () => { isOpen={isBrokerInfoOpen} > <DropdownList> - <DropdownItem key="attributes" component="button" onClick={() => openAttrubutes()}> + <DropdownItem key="attributes" component="button" onClick={() => openAttributes()}> Attributes </DropdownItem> <DropdownItem key="operations" component="button" onClick={() => openOperations()}> @@ -154,6 +155,31 @@ export const Status: React.FunctionComponent = () => { </Dropdown> ); + function actualValue(v: unknown): ReactNode { + if (typeof v === 'object' && v && (".error" in v) && v[".error"]) { + const msg = "error" in v ? v["error"] as string : "Not accessible" + return ( + <Tooltip content={msg}> + <LockedIcon /> + </Tooltip> + ) + } else { + return (v ? "" + v : "") + } + } + + if (!brokerState.loaded) { + return <Spinner size='lg' /> + } + + if (!brokerState.accessible) { + return ( + <PageSection id='broker-info' variant='light'> + <Alert variant="warning" title={brokerState.message} /> + </PageSection> + ) + } + return ( <> <Grid hasGutter> @@ -166,10 +192,10 @@ export const Status: React.FunctionComponent = () => { <Divider /> <TextContent> <TextList isPlain> - <TextListItem component={TextListItemVariants.dd}>Version: {brokerInfo?.version}</TextListItem> - <TextListItem component={TextListItemVariants.dd}>Uptime: {brokerInfo?.uptime}</TextListItem> - <TextListItem component={TextListItemVariants.dd}>Started: {""+brokerInfo?.started}</TextListItem> - <TextListItem component={TextListItemVariants.dd}>HA Policy: {brokerInfo?.haPolicy}</TextListItem> + <TextListItem component={TextListItemVariants.dd}>Version: {actualValue(brokerInfo?.version)}</TextListItem> + <TextListItem component={TextListItemVariants.dd}>Uptime: {actualValue(brokerInfo?.uptime)}</TextListItem> + <TextListItem component={TextListItemVariants.dd}>Started: {actualValue(brokerInfo?.started)}</TextListItem> + <TextListItem component={TextListItemVariants.dd}>HA Policy: {actualValue(brokerInfo?.haPolicy)}</TextListItem> </TextList> </TextContent> </CardBody> @@ -205,38 +231,46 @@ export const Status: React.FunctionComponent = () => { <ExpandableSection toggleTextExpanded="Acceptors" toggleTextCollapsed="Acceptors"> <Grid hasGutter span={4}> { - acceptors?.acceptors.map((acceptor, index) => ( - <GridItem key={index}> - <Card isFullHeight={true} isFlat={true}> + acceptors?.acceptors.map((acceptor, index) => { + if (!(typeof acceptor.Name === 'object' && ".error" in acceptor.Name)) { + return ( + <GridItem key={index}> + <Card isFullHeight={true} isFlat={true}> - <CardTitle>{acceptor.Name} ({acceptor.FactoryClassName.indexOf("Netty") === -1?"VM":"TCP"}): {acceptor.Started && <OkIcon color="green" />}{!acceptor.Started && <ExclamationCircleIcon color="red"/>}</CardTitle> - <CardBody> - <Divider /> - <Table variant="compact" aria-label="Column Management Table"> - <Thead> - <Tr key={"acceptor-list-param-title"}> - <Th key={"acceptor-list-param-key" + index}>key</Th> - <Th key={"acceptor-list-param-value" + index}>value</Th> - </Tr> - </Thead> - <Tbody> - { - Object.keys(acceptor.Parameters).map((key, index) => { - return ( - <Tr key={"acceptor-list-param-val-" + index}> - <Td key={"acceptor-params-key-" + key}>{key}</Td> - <Td key={"acceptor-params-val-" + key}>{acceptor.Parameters[key]}</Td> + <CardTitle>{acceptor.Name} ({typeof acceptor?.FactoryClassName === 'string' ? (acceptor?.FactoryClassName?.indexOf("Netty") === -1 ? "VM" : "TCP") : "?"}): {acceptor.Started && + <OkIcon color="green" />}{!acceptor.Started && + <ExclamationCircleIcon color="red" />}</CardTitle> + <CardBody> + <Divider /> + <Table variant="compact" aria-label="Column Management Table"> + <Thead> + <Tr key={"acceptor-list-param-title"}> + <Th key={"acceptor-list-param-key" + index}>key</Th> + <Th key={"acceptor-list-param-value" + index}>value</Th> </Tr> + </Thead> + <Tbody> + { + Object.keys(acceptor.Parameters).map((key, index) => { + return ( + <Tr key={"acceptor-list-param-val-" + index}> + <Td key={"acceptor-params-key-" + key}>{key}</Td> + <Td key={"acceptor-params-val-" + key}>{acceptor.Parameters[key]}</Td> + </Tr> - ) - }) - } - </Tbody> - </Table> - </CardBody> - </Card> - </GridItem> - )) + ) + }) + } + </Tbody> + </Table> + </CardBody> + </Card> + </GridItem> + )} else { + // return (<span>{acceptor.Name["message"]}</span>) + return null + } + }) } </Grid> </ExpandableSection> @@ -282,7 +316,7 @@ export const Status: React.FunctionComponent = () => { </Grid> <Grid hasGutter> { - brokerInfo?.networkTopology.brokers.map((broker, index) => ( + brokerInfo?.networkTopology?.brokers?.map?.((broker, index) => ( <GridItem key={index} span={3}> <Card isFlat={true}> <CardTitle>{broker.nodeID}</CardTitle> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected] For further information, visit: https://activemq.apache.org/contact
