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 { 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(); for (const line of dataLines) { const entry: any = {}; 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 { 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(); 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(/^./, 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`); }