Merge pull request #30860 from backstage/k8s-plugin-support-creds-json
[K8s plugin] Support google service account creds json
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
---
|
||||
|
||||
Added support for Google Service account credentials config to use in GoogleServiceAccountStrategy
|
||||
+7
@@ -98,6 +98,13 @@ export interface Config {
|
||||
plural: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* (Optional) Google Service Account credentials for authentication
|
||||
* JSON string containing the service account key
|
||||
* @visibility secret
|
||||
*/
|
||||
googleServiceAccountCredentials?: string;
|
||||
|
||||
/**
|
||||
* (Optional) API Version Overrides
|
||||
* If set, the specified api version will be used to make requests for the corresponding object.
|
||||
|
||||
@@ -90,8 +90,9 @@ export type DispatchStrategyOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export class GoogleServiceAccountStrategy implements AuthenticationStrategy {
|
||||
constructor(opts: { config: Config });
|
||||
// (undocumented)
|
||||
getCredential(): Promise<KubernetesCredential>;
|
||||
// (undocumented)
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { GoogleServiceAccountStrategy } from './GoogleServiceAccountStrategy';
|
||||
|
||||
// Mock the @google-cloud/container module
|
||||
const mockGetAccessToken = jest.fn();
|
||||
|
||||
jest.mock('@google-cloud/container', () => {
|
||||
const mockClusterManagerClient = jest.fn().mockImplementation(() => ({
|
||||
auth: {
|
||||
getAccessToken: mockGetAccessToken,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
v1: {
|
||||
ClusterManagerClient: mockClusterManagerClient,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get reference to the mocked constructor for use in tests
|
||||
const {
|
||||
v1: { ClusterManagerClient: MockedClusterManagerClient },
|
||||
} = jest.mocked(require('@google-cloud/container'));
|
||||
|
||||
describe('GoogleServiceAccountStrategy', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should read credentials from config when provided', () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {
|
||||
googleServiceAccountCredentials: '{"type": "service_account"}',
|
||||
},
|
||||
});
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work without credentials in config', () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {},
|
||||
});
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with empty config', () => {
|
||||
const config = new ConfigReader({});
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getCredential', () => {
|
||||
it('should use credentials from config when provided', async () => {
|
||||
const serviceAccountKey = {
|
||||
type: 'service_account',
|
||||
project_id: 'test-project',
|
||||
private_key_id: 'key-id',
|
||||
private_key:
|
||||
'-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----\n',
|
||||
client_email: 'test@test-project.iam.gserviceaccount.com',
|
||||
client_id: '123456789',
|
||||
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
|
||||
token_uri: 'https://oauth2.googleapis.com/token',
|
||||
};
|
||||
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {
|
||||
googleServiceAccountCredentials: JSON.stringify(serviceAccountKey),
|
||||
},
|
||||
});
|
||||
|
||||
mockGetAccessToken.mockResolvedValue('test-access-token');
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
const credential = await strategy.getCredential();
|
||||
|
||||
expect(MockedClusterManagerClient).toHaveBeenCalledWith({
|
||||
credentials: serviceAccountKey,
|
||||
});
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
expect(credential).toEqual({
|
||||
type: 'bearer token',
|
||||
token: 'test-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to default credentials when no config provided', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {},
|
||||
});
|
||||
|
||||
mockGetAccessToken.mockResolvedValue('default-access-token');
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
const credential = await strategy.getCredential();
|
||||
|
||||
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
expect(credential).toEqual({
|
||||
type: 'bearer token',
|
||||
token: 'default-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when JSON parsing fails', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {
|
||||
googleServiceAccountCredentials: 'invalid-json',
|
||||
},
|
||||
});
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
'Failed to parse Google Service Account credentials from config',
|
||||
);
|
||||
|
||||
expect(MockedClusterManagerClient).not.toHaveBeenCalled();
|
||||
expect(mockGetAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when access token is null', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {},
|
||||
});
|
||||
|
||||
mockGetAccessToken.mockResolvedValue(null);
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
'Unable to obtain access token for Google Cloud authentication',
|
||||
);
|
||||
|
||||
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when access token is undefined', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {},
|
||||
});
|
||||
|
||||
mockGetAccessToken.mockResolvedValue(undefined);
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
'Unable to obtain access token for Google Cloud authentication',
|
||||
);
|
||||
|
||||
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty string access token', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {},
|
||||
});
|
||||
|
||||
mockGetAccessToken.mockResolvedValue('');
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
'Unable to obtain access token for Google Cloud authentication',
|
||||
);
|
||||
|
||||
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle malformed JSON with specific error message', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {
|
||||
googleServiceAccountCredentials: '{"invalid": json}',
|
||||
},
|
||||
});
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
/Failed to parse Google Service Account credentials from config: Unexpected token/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle client creation errors', async () => {
|
||||
const serviceAccountKey = {
|
||||
type: 'service_account',
|
||||
project_id: 'test-project',
|
||||
};
|
||||
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {
|
||||
googleServiceAccountCredentials: JSON.stringify(serviceAccountKey),
|
||||
},
|
||||
});
|
||||
|
||||
MockedClusterManagerClient.mockImplementationOnce(() => {
|
||||
throw new Error('Client creation failed');
|
||||
});
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
'Client creation failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle getAccessToken errors', async () => {
|
||||
const config = new ConfigReader({
|
||||
kubernetes: {},
|
||||
});
|
||||
|
||||
mockGetAccessToken.mockRejectedValue(new Error('Token fetch failed'));
|
||||
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
await expect(strategy.getCredential()).rejects.toThrow(
|
||||
'Token fetch failed',
|
||||
);
|
||||
|
||||
expect(MockedClusterManagerClient).toHaveBeenCalledWith();
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#validateCluster', () => {
|
||||
it('should return empty array', () => {
|
||||
const config = new ConfigReader({});
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
const result = strategy.validateCluster();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#presentAuthMetadata', () => {
|
||||
it('should return empty object', () => {
|
||||
const config = new ConfigReader({});
|
||||
const strategy = new GoogleServiceAccountStrategy({ config });
|
||||
|
||||
const result = strategy.presentAuthMetadata({ test: 'metadata' });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,19 +20,69 @@ import {
|
||||
KubernetesCredential,
|
||||
} from '@backstage/plugin-kubernetes-node';
|
||||
import * as container from '@google-cloud/container';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* GoogleServiceAccountStrategy provides authentication using Google Service Account credentials.
|
||||
*
|
||||
* Credentials can be provided via configuration:
|
||||
* ```yaml
|
||||
* kubernetes:
|
||||
* googleServiceAccountCredentials: |
|
||||
* {
|
||||
* "type": "service_account",
|
||||
* "project_id": "your-project-id",
|
||||
* "private_key_id": "key-id",
|
||||
* "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
|
||||
* "client_email": "your-service-account@your-project.iam.gserviceaccount.com",
|
||||
* "client_id": "client-id",
|
||||
* "auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
* "token_uri": "https://oauth2.googleapis.com/token",
|
||||
* "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
* "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/..."
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* If no credentials are provided in config, falls back to GOOGLE_APPLICATION_CREDENTIALS or ADC.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class GoogleServiceAccountStrategy implements AuthenticationStrategy {
|
||||
private readonly credentials?: string;
|
||||
|
||||
constructor(opts: { config: Config }) {
|
||||
this.credentials = opts.config.getOptionalString(
|
||||
'kubernetes.googleServiceAccountCredentials',
|
||||
);
|
||||
}
|
||||
public async getCredential(): Promise<KubernetesCredential> {
|
||||
const client = new container.v1.ClusterManagerClient();
|
||||
let client: container.v1.ClusterManagerClient;
|
||||
|
||||
if (this.credentials) {
|
||||
// Use credentials from config
|
||||
try {
|
||||
const credentialsObject = JSON.parse(this.credentials);
|
||||
|
||||
client = new container.v1.ClusterManagerClient({
|
||||
credentials: credentialsObject,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse Google Service Account credentials from config: ${
|
||||
error instanceof Error ? error.message : 'Invalid JSON'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fall back to Application Default Credentials or GOOGLE_APPLICATION_CREDENTIALS
|
||||
client = new container.v1.ClusterManagerClient();
|
||||
}
|
||||
|
||||
const token = await client.auth.getAccessToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Unable to obtain access token for the current Google Application Default Credentials',
|
||||
'Unable to obtain access token for Google Cloud authentication. Check your credentials configuration.',
|
||||
);
|
||||
}
|
||||
return { type: 'bearer token', token };
|
||||
|
||||
@@ -462,7 +462,9 @@ export class KubernetesBuilder {
|
||||
aws: new AwsIamStrategy({ config: this.env.config }),
|
||||
azure: new AzureIdentityStrategy(this.env.logger),
|
||||
google: new GoogleStrategy(),
|
||||
googleServiceAccount: new GoogleServiceAccountStrategy(),
|
||||
googleServiceAccount: new GoogleServiceAccountStrategy({
|
||||
config: this.env.config,
|
||||
}),
|
||||
localKubectlProxy: new AnonymousStrategy(),
|
||||
oidc: new OidcStrategy(),
|
||||
serviceAccount: new ServiceAccountStrategy(),
|
||||
|
||||
Reference in New Issue
Block a user