Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

grpc-js-xds: Add bootstrap certificate provider config handling #2823

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions packages/grpc-js-xds/src/xds-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import * as fs from 'fs';
import { EXPERIMENTAL_FEDERATION } from './environment';
import { Struct } from './generated/google/protobuf/Struct';
import { Value } from './generated/google/protobuf/Value';
import { experimental } from '@grpc/grpc-js';

import parseDuration = experimental.parseDuration;
import durationToMs = experimental.durationToMs;
import FileWatcherCertificateProviderConfig = experimental.FileWatcherCertificateProviderConfig;

/* eslint-disable @typescript-eslint/no-explicit-any */

Expand Down Expand Up @@ -51,12 +56,20 @@ export interface Authority {
xdsServers?: XdsServerConfig[];
}

export type PluginConfig = FileWatcherCertificateProviderConfig;

export interface CertificateProviderConfig {
pluginName: string;
config: PluginConfig;
}

export interface BootstrapInfo {
xdsServers: XdsServerConfig[];
node: Node;
authorities: {[authorityName: string]: Authority};
clientDefaultListenerResourceNameTemplate: string;
serverListenerResourceNameTemplate: string | null;
certificateProviders: {[instanceName: string]: CertificateProviderConfig};
}

const KNOWN_SERVER_FEATURES = ['ignore_resource_deletion'];
Expand Down Expand Up @@ -306,6 +319,71 @@ function validateAuthoritiesMap(obj: any): {[authorityName: string]: Authority}
return result;
}

function validateFileWatcherPluginConfig(obj: any, instanceName: string): FileWatcherCertificateProviderConfig {
if ('certificate_file' in obj && typeof obj.certificate_file !== 'string') {
throw new Error(`certificate_providers[${instanceName}].config.certificate_file: expected string, got ${typeof obj.certificate_file}`);
}
if ('private_key_file' in obj && typeof obj.private_key_file !== 'string') {
throw new Error(`certificate_providers[${instanceName}].config.private_key_file: expected string, got ${typeof obj.private_key_file}`);
}
if ('ca_certificate_file' in obj && typeof obj.ca_certificate_file !== 'string') {
throw new Error(`certificate_providers[${instanceName}].config.ca_certificate_file: expected string, got ${typeof obj.ca_certificate_file}`);
}
if (typeof obj.refresh_interval !== 'string') {
throw new Error(`certificate_providers[${instanceName}].config.refresh_interval: expected string, got ${typeof obj.refresh_interval}`);
}
if (('private_key_file' in obj) !== ('certificate_file' in obj)) {
throw new Error(`certificate_providers[${instanceName}].config: private_key_file and certificate_file must be provided or omitted together`);
}
if (!('private_key_file' in obj) && !('ca_certificate_file' in obj)) {
throw new Error(`certificate_providers[${instanceName}].config: either private_key_file and certificate_file or ca_certificate_file must be set`);
}
const refreshDuration = parseDuration(obj.refresh_interval);
if (!refreshDuration) {
throw new Error(`certificate_providers[${instanceName}].config.refresh_interval: failed to parse duration from value ${obj.refresh_interval}`);
}
return {
certificateFile: obj.certificate_file,
privateKeyFile: obj.private_key_file,
caCertificateFile: obj.caCertificateFile,
refreshIntervalMs: durationToMs(refreshDuration)
};
}

const pluginConfigValidators: {[typeName: string]: (obj: any, instanceName: string) => PluginConfig} = {
'file_watcher': validateFileWatcherPluginConfig
};

function validateCertificateProvider(obj: any, instanceName: string): CertificateProviderConfig {
if (!('plugin_name' in obj) || typeof obj.plugin_name !== 'string') {
throw new Error(`certificate_providers[${instanceName}].plugin_name: expected string, got ${typeof obj.plugin_name}`);
}
if (!(obj.plugin_name in pluginConfigValidators)) {
throw new Error(`certificate_providers[${instanceName}]: unknown plugin_name ${obj.plugin_name}`);
}
if (!obj.config) {
throw new Error(`certificate_providers[${instanceName}].config: expected object, got ${typeof obj.config}`);
}
if (!(obj.plugin_name in pluginConfigValidators)) {
throw new Error(`certificate_providers[${instanceName}].config: unknown plugin_name ${obj.plugin_name}`);
}
return {
pluginName: obj.plugin_name,
config: pluginConfigValidators[obj.plugin_name]!(obj.config, instanceName)
};
}

function validateCertificateProvidersMap(obj: any): {[instanceName: string]: CertificateProviderConfig} {
if (!obj) {
return {};
}
const result: {[instanceName: string]: CertificateProviderConfig} = {};
for (const [name, provider] of Object.entries(obj)) {
result[name] = validateCertificateProvider(provider, name);
}
return result;
}

export function validateBootstrapConfig(obj: any): BootstrapInfo {
const xdsServers = obj.xds_servers.map(validateXdsServerConfig);
const node = validateNode(obj.node);
Expand All @@ -325,15 +403,17 @@ export function validateBootstrapConfig(obj: any): BootstrapInfo {
node: node,
authorities: validateAuthoritiesMap(obj.authorities),
clientDefaultListenerResourceNameTemplate: obj.client_default_listener_resource_name_template ?? '%s',
serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null
serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null,
certificateProviders: validateCertificateProvidersMap(obj.certificate_providers)
};
} else {
return {
xdsServers: xdsServers,
node: node,
authorities: {},
clientDefaultListenerResourceNameTemplate: '%s',
serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null
serverListenerResourceNameTemplate: obj.server_listener_resource_name_template ?? null,
certificateProviders: validateCertificateProvidersMap(obj.certificate_providers)
};
}
}
Expand Down
25 changes: 24 additions & 1 deletion packages/grpc-js-xds/src/xds-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Channel, ChannelCredentials, ClientDuplexStream, Metadata, StatusObject
import { XdsDecodeContext, XdsDecodeResult, XdsResourceType } from "./xds-resource-type/xds-resource-type";
import { XdsResourceName, parseXdsResourceName, xdsResourceNameToString } from "./resources";
import { Node } from "./generated/envoy/config/core/v3/Node";
import { BootstrapInfo, XdsServerConfig, loadBootstrapInfo, serverConfigEqual } from "./xds-bootstrap";
import { BootstrapInfo, CertificateProviderConfig, XdsServerConfig, loadBootstrapInfo, serverConfigEqual } from "./xds-bootstrap";
import BackoffTimeout = experimental.BackoffTimeout;
import { DiscoveryRequest } from "./generated/envoy/service/discovery/v3/DiscoveryRequest";
import { DiscoveryResponse__Output } from "./generated/envoy/service/discovery/v3/DiscoveryResponse";
Expand All @@ -35,6 +35,8 @@ import { LoadStatsResponse__Output } from "./generated/envoy/service/load_stats/
import { Locality, Locality__Output } from "./generated/envoy/config/core/v3/Locality";
import { Duration } from "./generated/google/protobuf/Duration";
import { registerXdsClientWithCsds } from "./csds";
import CertificateProvider = experimental.CertificateProvider;
import FileWatcherCertificateProvider = experimental.FileWatcherCertificateProvider;

const TRACER_NAME = 'xds_client';

Expand Down Expand Up @@ -1111,6 +1113,15 @@ interface AuthorityState {

const userAgentName = 'gRPC Node Pure JS';

function createCertificateProvider(config: CertificateProviderConfig) {
switch (config.pluginName) {
case 'file_watcher':
return new FileWatcherCertificateProvider(config.config);
default:
throw new Error(`Unexpected certificate provider plugin name ${config.pluginName}`);
}
}

export class XdsClient {
/**
* authority -> authority state
Expand All @@ -1119,6 +1130,8 @@ export class XdsClient {
private clients: ClientMapEntry[] = [];
private typeRegistry: Map<string, XdsResourceType> = new Map();
private bootstrapInfo: BootstrapInfo | null = null;
private certificateProviderRegistry: Map<string, CertificateProvider> = new Map();
private certificateProviderRegistryPopulated = false;

constructor(bootstrapInfoOverride?: BootstrapInfo) {
if (bootstrapInfoOverride) {
Expand Down Expand Up @@ -1298,6 +1311,16 @@ export class XdsClient {
removeClusterLocalityStats(lrsServer: XdsServerConfig, clusterName: string, edsServiceName: string, locality: Locality__Output) {
this.getClient(lrsServer)?.removeClusterLocalityStats(clusterName, edsServiceName, locality);
}

getCertificateProvider(instanceName: string): CertificateProvider | undefined {
if (!this.certificateProviderRegistryPopulated) {
for (const [name, config] of Object.entries(this.getBootstrapInfo().certificateProviders)) {
this.certificateProviderRegistry.set(name, createCertificateProvider(config));
}
this.certificateProviderRegistryPopulated = true;
}
return this.certificateProviderRegistry.get(instanceName);
}
}

let singletonXdsClient: XdsClient | null = null;
Expand Down
4 changes: 0 additions & 4 deletions packages/grpc-js/src/certificate-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ export interface CertificateProvider {
removeIdentityCertificateListener(listener: IdentityCertificateUpdateListener): void;
}

export interface CertificateProviderProvider<Provider> {
getInstance(): Provider;
}

export interface FileWatcherCertificateProviderConfig {
certificateFile?: string | undefined;
privateKeyFile?: string | undefined;
Expand Down
12 changes: 12 additions & 0 deletions packages/grpc-js/src/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,15 @@ export function durationToMs(duration: Duration): number {
export function isDuration(value: any): value is Duration {
return typeof value.seconds === 'number' && typeof value.nanos === 'number';
}

const durationRegex = /^(\d+)(?:\.(\d+))?s$/;
export function parseDuration(value: string): Duration | null {
const match = value.match(durationRegex);
if (!match) {
return null;
}
return {
seconds: Number.parseInt(match[1], 10),
nanos: Number.parseInt(match[2].padEnd(9, '0'), 10)
};
}
2 changes: 1 addition & 1 deletion packages/grpc-js/src/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export {
createResolver,
} from './resolver';
export { GrpcUri, uriToString, splitHostPort, HostPort } from './uri-parser';
export { Duration, durationToMs } from './duration';
export { Duration, durationToMs, parseDuration } from './duration';
export { BackoffTimeout } from './backoff-timeout';
export {
LoadBalancer,
Expand Down
Loading