cdk8s-plus-25
Version:
cdk8s+ is a software development framework that provides high level abstractions for authoring Kubernetes applications. cdk8s-plus-25 synthesizes Kubernetes manifests for Kubernetes 1.25.0
350 lines (314 loc) • 11.7 kB
text/typescript
import * as fs from 'fs';
import * as path from 'path';
import { Project, SourceCode } from 'projen';
import { snakeCase } from 'snake-case';
/**
* Generate a source file for the `ApiResource` module, based on information about API
* resources generated by running `kubectl api-resources -o wide`.
*
* We do this because `kubectl api-resources` doesn't support JSON output
* formatting, and at the time of writing, parsing this command output seemed
* simpler than extracting information from the OpenAPI schema.
*/
export function generateApiResources(project: Project, sourcePath: string, outputPath: string) {
const resourceTypes = parseApiResources(sourcePath);
const ts = new SourceCode(project, outputPath);
if (ts.marker) {
ts.line(`// ${ts.marker}`);
}
ts.line();
ts.line('/**');
ts.line(' * Represents a resource or collection of resources.');
ts.line(' */');
ts.open('export interface IApiResource {');
ts.line('/**');
ts.line(' * The group portion of the API version (e.g. `authorization.k8s.io`).');
ts.line(' */');
ts.line('readonly apiGroup: string;');
ts.line();
ts.line('/**');
ts.line(' * The name of a resource type as it appears in the relevant API endpoint.');
ts.line(' * @example - "pods" or "pods/log"');
ts.line(' * @see https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources');
ts.line(' */');
ts.line('readonly resourceType: string;');
ts.line();
ts.line('/**');
ts.line(' * The unique, namespace-global, name of an object inside the Kubernetes cluster.');
ts.line(' *');
ts.line(' * If this is omitted, the ApiResource should represent all objects of the given type.');
ts.line(' */');
ts.line('readonly resourceName?: string;');
ts.close('}');
ts.line('/**');
ts.line(' * An API Endpoint can either be a resource descriptor (e.g /pods)');
ts.line(' * or a non resource url (e.g /healthz). It must be one or the other, and not both.');
ts.line(' */');
ts.open('export interface IApiEndpoint {');
ts.line('');
ts.line('/**');
ts.line(' * Return the IApiResource this object represents.');
ts.line(' */');
ts.line('asApiResource(): IApiResource | undefined;');
ts.line('');
ts.line('/**');
ts.line(' * Return the non resource url this object represents.');
ts.line(' */');
ts.line('asNonApiResource(): string | undefined;');
ts.line('');
ts.close('}');
ts.line();
ts.line('/**');
ts.line(' * Options for `ApiResource`.');
ts.line(' */');
ts.open('export interface ApiResourceOptions {');
ts.line('/**');
ts.line(' * The group portion of the API version (e.g. `authorization.k8s.io`).');
ts.line(' */');
ts.line('readonly apiGroup: string;');
ts.line();
ts.line('/**');
ts.line(' * The name of the resource type as it appears in the relevant API endpoint.');
ts.line(' * @example - "pods" or "pods/log"');
ts.line(' * @see https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources');
ts.line(' */');
ts.line('readonly resourceType: string;');
ts.close('}');
ts.line();
ts.line('/**');
ts.line(' * Represents information about an API resource type.');
ts.line(' */');
ts.open('export class ApiResource implements IApiResource, IApiEndpoint {');
for (const resource of resourceTypes) {
const typeName = normalizeTypeName(resource.kind);
let memberName = snakeCase(typeName.replace(/[^a-z0-9]/gi, '_')).split('_').filter(x => x).join('_').toUpperCase();
// Pluralize the resource name -- we strip some characters off of memberName
// in order to handle some weird english plurals, e.g.
// "CSI_STORAGE_CAPACITY" -> "CSI_STORAGE_CAPACITIES"
const pluralSuffix = resource.name.substring(resource.kind.length - 1).toUpperCase();
memberName = memberName.slice(0, -1) + pluralSuffix;
const apiGroups = resource.apiVersions.map(parseApiGroup);
ts.line('/**');
ts.line(` * API resource information for ${resource.kind}.`);
ts.line(' */');
ts.open(`public static readonly ${memberName} = new ApiResource({`);
ts.line(`apiGroup: '${apiGroups[0]}',`);
ts.line(`resourceType: '${resource.name}',`);
ts.close('});');
ts.line();
}
ts.line('/**');
ts.line(' * API resource information for a custom resource type.');
ts.line(' */');
ts.open('public static custom(options: ApiResourceOptions): ApiResource {');
ts.line('return new ApiResource(options);');
ts.close('};');
ts.line();
ts.line('/**');
ts.line(' * The group portion of the API version (e.g. `authorization.k8s.io`).');
ts.line(' */');
ts.line('public readonly apiGroup: string;');
ts.line();
ts.line('/**');
ts.line(' * The name of the resource type as it appears in the relevant API endpoint.');
ts.line(' * @example - "pods" or "pods/log"');
ts.line(' * @see https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources');
ts.line(' */');
ts.line('public readonly resourceType: string;');
ts.line();
ts.open('public asApiResource(): IApiResource | undefined {');
ts.line('return this;');
ts.close('}');
ts.line('');
ts.open('public asNonApiResource(): string | undefined {');
ts.line('return undefined;');
ts.close('}');
ts.open('private constructor(options: ApiResourceOptions) {');
ts.line('this.apiGroup = options.apiGroup;');
ts.line('this.resourceType = options.resourceType;');
ts.close('}');
ts.close('}');
ts.line();
ts.line('/**');
ts.line(' * Factory for creating non api resources.');
ts.line(' */');
ts.open('export class NonApiResource implements IApiEndpoint {');
ts.line('');
ts.open('public static of(url: string): NonApiResource {');
ts.line('return new NonApiResource(url);');
ts.close('}');
ts.line('');
ts.line('private constructor(private readonly nonResourceUrl: string) {};');
ts.line();
ts.open('public asApiResource(): IApiResource | undefined {');
ts.line('return undefined;');
ts.close('}');
ts.line('');
ts.open('public asNonApiResource(): string | undefined {');
ts.line();
ts.line('return this.nonResourceUrl;');
ts.close('}');
ts.close('}');
}
/**
* Extract structured API resource information from the textual output of the
* `kubectl api-resources -o wide` command.
*/
function parseApiResources(filename: string): Array<ApiResourceEntry> {
const fileContents = fs.readFileSync(path.join(filename)).toString();
const lines = fileContents.split('\n');
const header = lines[0];
const dataLines = lines.slice(1);
const columns = calculateColumnMetadata(header);
const apiResources = new Array<ApiResourceEntry>();
for (const line of dataLines) {
const entry: any = {};
if (line == '') {
continue;
}
for (const column of columns) {
const value = line.slice(column.start, column.end).trim();
entry[column.title.toLowerCase()] = value;
}
const massaged = sanitizeData(entry);
apiResources.push(massaged);
}
combineResources(apiResources);
return apiResources;
}
/**
* Sanitize data that has been parsed from `kubectl api-resources -o wide`
* from string types into JavaScript values like booleans and arrays.
*/
function sanitizeData(entry: any): ApiResourceEntry {
let shortnames = entry.shortnames.split(',');
shortnames = shortnames[0].length === 0 ? [] : shortnames;
return {
name: entry.name,
shortnames,
apiVersions: [entry.apiversion],
namespaced: Boolean(entry.namespaced),
kind: entry.kind,
verbs: entry.verbs.slice(1, -1).split(' '),
};
}
/**
* Sometimes resources have multiple API versions (e.g. events is listed under
* "v1" and "events.k8s.io/v1"), so we combine them together.
*/
function combineResources(resources: ApiResourceEntry[]) {
let i = 0;
while (i < resources.length) {
let didCombine = false;
for (let j = i + 1; j < resources.length; j++) {
if (resources[i].kind === resources[j].kind
&& resources[i].name === resources[j].name
&& resources[i].namespaced === resources[j].namespaced
) {
const combined: ApiResourceEntry = {
kind: resources[i].kind,
name: resources[i].name,
apiVersions: Array.from(new Set(
[...resources[i].apiVersions, ...resources[j].apiVersions],
)),
namespaced: resources[i].namespaced,
shortnames: Array.from(new Set(
[...resources[i].shortnames, ...resources[j].shortnames],
)),
verbs: Array.from(new Set(
[...resources[i].verbs, ...resources[j].verbs],
)),
};
resources[i] = combined;
resources.splice(j, 1);
didCombine = true;
break;
}
}
if (!didCombine) {
i++;
}
}
}
interface ApiResourceEntry {
readonly name: string;
readonly shortnames: string[];
readonly apiVersions: string[];
readonly namespaced: boolean;
readonly kind: string;
readonly verbs: string[];
}
interface Column {
readonly title: string;
readonly start: number;
readonly end?: number; // last column does not have an end index
}
/**
* Given a string of this form:
*
* "NAME SHORTNAMES APIVERSION"
* indices: 0 10 22 31
*
* we return an array like:
*
* [{ title: "NAME", start: 0, end: 10 },
* { title: "SHORTNAMES", start: 10, end: 22 },
* { title: "APIVERSION", start: 22, end: 31 }]
*/
function calculateColumnMetadata(header: string): Array<Column> {
const headerRegex = /([A-Z]+)(\s+)([A-Z]+)(\s+)([A-Z]+)(\s+)([A-Z]+)(\s+)([A-Z]+)(\s+)([A-Z]+)/;
const matches = headerRegex.exec(header)!;
const columns = new Array<Column>();
let currIndex = 0;
for (let matchIdx = 1; matchIdx < matches.length - 1; matchIdx += 2) {
const start = currIndex;
const title = matches[matchIdx];
currIndex += matches[matchIdx].length;
currIndex += matches[matchIdx + 1].length;
const end = currIndex;
columns.push({ title, start, end });
}
// add last column as special case
columns.push({ title: matches[matches.length - 1], start: currIndex });
return columns;
}
/**
* Convert all-caps acronyms (e.g. "VPC", "FooBARZooFIGoo") to pascal case
* (e.g. "Vpc", "FooBarZooFiGoo").
*
* note: code borrowed from json2jsii
*/
function normalizeTypeName(typeName: string) {
// start with the full string and then use the regex to match all-caps sequences.
const re = /([A-Z]+)(?:[^a-z]|$)/g;
let result = typeName;
let m;
do {
m = re.exec(typeName);
if (m) {
const before = result.slice(0, m.index); // all the text before the sequence
const cap = m[1]; // group #1 matches the all-caps sequence we are after
const pascal = cap[0] + cap.slice(1).toLowerCase(); // convert to pascal case by lowercasing all but the first char
const after = result.slice(m.index + pascal.length); // all the text after the sequence
result = before + pascal + after; // concat
}
} while (m);
result = result.replace(/^\S/, result[0]?.toUpperCase()); // ensure first letter is capitalized
return result;
}
/**
* Parses the apiGroup from an apiVersion.
* @example "admissionregistration.k8s.io/v1" => "admissionregistration.k8s.io"
*/
function parseApiGroup(apiVersion: string) {
const v = apiVersion.split('/');
// no group means it's in the core group
// https://kubernetes.io/docs/reference/using-api/api-overview/#api-groups
if (v.length === 1) {
return '';
}
if (v.length === 2) {
return v[0];
}
throw new Error(`invalid apiVersion ${apiVersion}, expecting GROUP/VERSION. See https://kubernetes.io/docs/reference/using-api/api-overview/#api-groups`);
}