blob: cf17f37a8c05da8f380f7aaa6e4e67d6e1de5b39 [file]
/*!
*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*
*/
/* eslint-env jest */
import * as grpc from '@grpc/grpc-js';
import GRPCChannelManager from '../../src/agent/core/remote/GRPCChannelManager';
import { GRPCChannelStatus } from '../../src/agent/core/remote/GRPCChannelStatus';
import config from '../../src/config/AgentConfig';
const mockShutdownNow = jest.fn();
const mockGetConnectivityState = jest.fn();
const mockWatchConnectivityState = jest.fn();
const mockIsConnected = jest.fn(() => true);
const mockNewBuilder = jest.fn();
const mockWithChannelOptions = jest.fn().mockReturnThis();
jest.mock('../../src/agent/core/remote/GRPCChannel', () => ({
__esModule: true,
default: {
newBuilder: (...args: unknown[]) => mockNewBuilder(...args),
},
}));
function installChannelMock(): void {
mockNewBuilder.mockImplementation(() => ({
withChannelOptions: mockWithChannelOptions,
addManagedChannelBuilder: jest.fn().mockReturnThis(),
addChannelDecorator: jest.fn().mockReturnThis(),
build: jest.fn(() => ({
getChannel: () => ({
getConnectivityState: mockGetConnectivityState,
watchConnectivityState: mockWatchConnectivityState,
}),
getClientOptions: () => ({ channelOverride: {} }),
isConnected: mockIsConnected,
getConnectivityState: mockGetConnectivityState,
shutdownNow: mockShutdownNow,
})),
}));
}
describe('GRPCChannelManager (native grpc-js multi-backend failover)', () => {
const originalCollector = config.collectorAddress;
const originalSecure = config.secure;
beforeEach(() => {
jest.clearAllMocks();
installChannelMock();
mockWatchConnectivityState.mockImplementation(() => undefined);
mockGetConnectivityState.mockReturnValue(grpc.connectivityState.READY);
mockIsConnected.mockReturnValue(true);
config.collectorAddress = '127.0.0.1:11800';
config.secure = false;
jest.spyOn(Math, 'random').mockReturnValue(0);
});
afterEach(() => {
config.collectorAddress = originalCollector;
config.secure = originalSecure;
jest.restoreAllMocks();
});
it('notifies CONNECTED when channel is READY after boot', () => {
const listener = { statusChanged: jest.fn() };
const manager = new GRPCChannelManager();
manager.addChannelListener(listener);
manager.boot();
expect(mockNewBuilder).toHaveBeenCalled();
expect(mockWatchConnectivityState).toHaveBeenCalled();
expect(listener.statusChanged).toHaveBeenCalledWith(GRPCChannelStatus.CONNECTED);
expect(mockWithChannelOptions).toHaveBeenCalled();
const options = mockWithChannelOptions.mock.calls[0][0];
expect(options['grpc.enable_http_proxy']).toBe(0);
expect(options['grpc.keepalive_time_ms']).toBeUndefined();
manager.shutdown();
});
it('uses plain host:port target for a single backend', () => {
config.collectorAddress = 'oap.example.com:11800';
const manager = new GRPCChannelManager();
manager.boot();
expect(mockNewBuilder.mock.calls[0][0]).toBe('oap.example.com:11800');
manager.shutdown();
});
it('builds sw-static multi-address target for multiple backends', () => {
config.collectorAddress = '10.0.0.1:11800,10.0.0.2:11800';
const manager = new GRPCChannelManager();
manager.boot();
const target = mockNewBuilder.mock.calls[0][0] as string;
expect(target.startsWith('sw-static:///')).toBe(true);
expect(target).toContain('10.0.0.1:11800');
expect(target).toContain('10.0.0.2:11800');
manager.shutdown();
});
it('preserves config address order in the channel target (LB shuffles endpoints)', () => {
config.collectorAddress = 'a:11800,b:11800';
jest.spyOn(Math, 'random').mockReturnValue(0.99);
const manager = new GRPCChannelManager();
manager.boot();
expect(mockNewBuilder.mock.calls[0][0]).toBe('sw-static:///a:11800,b:11800');
manager.shutdown();
});
it('preserves address order under TLS for stable authority', () => {
config.secure = true;
config.collectorAddress = 'a:11800,b:11800';
jest.spyOn(Math, 'random').mockReturnValue(0.99);
const manager = new GRPCChannelManager();
manager.boot();
expect(mockNewBuilder.mock.calls[0][0]).toBe('sw-static:///a:11800,b:11800');
manager.shutdown();
});
it('notifies DISCONNECT when collector addresses are empty', () => {
config.collectorAddress = '';
const listener = { statusChanged: jest.fn() };
const manager = new GRPCChannelManager();
manager.addChannelListener(listener);
manager.boot();
expect(mockNewBuilder).not.toHaveBeenCalled();
expect(listener.statusChanged).toHaveBeenCalledWith(GRPCChannelStatus.DISCONNECT);
manager.shutdown();
});
it('does not rebuild on network error while READY', () => {
const manager = new GRPCChannelManager();
manager.boot();
mockNewBuilder.mockClear();
mockShutdownNow.mockClear();
manager.reportError({ code: grpc.status.UNAVAILABLE, message: 'transient' });
expect(mockNewBuilder).not.toHaveBeenCalled();
expect(mockShutdownNow).not.toHaveBeenCalled();
manager.shutdown();
});
it('does not rebuild on UNAUTHENTICATED (auth is not fixed by rotating backends)', () => {
config.collectorAddress = 'a:11800,b:11800';
const manager = new GRPCChannelManager();
manager.boot();
const targetBefore = mockNewBuilder.mock.calls[0][0];
mockNewBuilder.mockClear();
manager.reportError({ code: grpc.status.UNAUTHENTICATED, message: 'bad token' });
expect(mockNewBuilder).not.toHaveBeenCalled();
expect(targetBefore).toContain('sw-static:///');
manager.shutdown();
});
it('does not treat CONNECTING as DISCONNECT', () => {
mockGetConnectivityState.mockReturnValue(grpc.connectivityState.CONNECTING);
const listener = { statusChanged: jest.fn() };
const manager = new GRPCChannelManager();
manager.addChannelListener(listener);
manager.boot();
expect(mockNewBuilder).toHaveBeenCalled();
expect(mockWatchConnectivityState).toHaveBeenCalled();
expect(listener.statusChanged).not.toHaveBeenCalledWith(GRPCChannelStatus.DISCONNECT);
expect(listener.statusChanged).not.toHaveBeenCalledWith(GRPCChannelStatus.CONNECTED);
manager.shutdown();
});
it('treats READY then IDLE as DISCONNECT', () => {
mockGetConnectivityState.mockReturnValue(grpc.connectivityState.READY);
const listener = { statusChanged: jest.fn() };
const manager = new GRPCChannelManager();
manager.addChannelListener(listener);
manager.boot();
expect(listener.statusChanged).toHaveBeenCalledWith(GRPCChannelStatus.CONNECTED);
mockGetConnectivityState.mockReturnValue(grpc.connectivityState.IDLE);
const watchCb = mockWatchConnectivityState.mock.calls[0][2] as (err?: Error) => void;
watchCb();
expect(listener.statusChanged).toHaveBeenCalledWith(GRPCChannelStatus.DISCONNECT);
manager.shutdown();
});
});