Skip to content

Commit

Permalink
feat: initialize @kubekit/prepare project for generating Kubernetes O…
Browse files Browse the repository at this point in the history
…penAPI specs based on ServiceAccount permissions
  • Loading branch information
kahirokunn committed May 10, 2024
1 parent 617edc3 commit 891504a
Show file tree
Hide file tree
Showing 27 changed files with 60,200 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ eks-blueprints-add-ons
examples/generate-vercel-client/swagger.json
eks-blueprints-add-ons
openapi
packages/kubekit-prepare/lib
1 change: 1 addition & 0 deletions packages/kubekit-prepare/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tmp
38 changes: 38 additions & 0 deletions packages/kubekit-prepare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Introduction

The `@kubekit/prepare` project is a tool that generates a JSON file of OpenAPI specifications that describe operations that can be executed based on the permissions of Kubernetes ServiceAccount. This tool is particularly useful for application developers who use Kubernetes API. The generated `openapi.json` file is used as a schema file to generate client code in the `@kubekit/codegen` project.

## Key Features

- **Accurate Permission Checks**: Generates OpenAPI specifications that only include API operations that can be executed based on the permissions of the ServiceAccount.
- **Scalability**: Currently, only `ServiceAccount` is supported, but support for `User` and `Group` is also planned in the future.
- **Strict Type Definitions**: Provides more strict type definitions than the standard Swagger definitions of Kubernetes, promoting safer API usage.

## Configuration

To use the project, you need to configure the `config.ts` file. The following example shows how to configure a [ServiceAccount](file:///Users/kahiro/Documents/appthrust/kubekit-ts/packages/kubekit-prepare/README.md#3%2C22-3%2C22).

```typescript:config.ts
import { ConfigFile } from "./config"

const config: ConfigFile = {
kind: 'ServiceAccount',
name: 'replicaset-controller',
namespace: 'kube-system',
outputFile: './replicaset-controller.openapi.json',
}

export default config
```

## Execution Method

After creating the configuration file, execute the following command to generate `replicaset-controller.openapi.json`.

```bash
npx @kubekit/prepare config.ts
```

## Future Prospects

This project is currently under development and plans to add support for `User` and `Group`, as well as further feature enhancements. This is aimed at enabling more users to effectively utilize Kubernetes API.
9 changes: 9 additions & 0 deletions packages/kubekit-prepare/gen-config/rbac-v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ConfigFile } from '@kubekit/codegen'

const config: ConfigFile = {
schemaFile: '../openapi/apis/rbac.authorization.k8s.io/v1/swagger.json',
apiFile: '@kubekit/client',
outputFile: '../src/k8s-client/rbac-v1.ts',
}

export default config
9 changes: 9 additions & 0 deletions packages/kubekit-prepare/gen-config/v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ConfigFile } from '@kubekit/codegen'

const config: ConfigFile = {
schemaFile: '../openapi/api/v1/swagger.json',
apiFile: '@kubekit/client',
outputFile: '../src/k8s-client/v1.ts',
}

export default config
52 changes: 52 additions & 0 deletions packages/kubekit-prepare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@kubekit/prepare",
"version": "0.0.8",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"author": "kahirokunn",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/appthrust/kubekit-ts.git"
},
"bugs": {
"url": "https://github.com/appthrust/kubekit-ts/issues"
},
"homepage": "https://github.com/appthrust/kubekit-ts",
"bin": "lib/bin/cli.js",
"scripts": {
"build": "tsc",
"prepare": "tsc && chmod +x ./lib/bin/cli.js",
"sync": "npx @kubekit/sync ./",
"gen": "npx @kubekit/codegen gen-config/v1.ts && npx @kubekit/codegen gen-config/rbac-v1.ts",
"cli": "lib/bin/cli.js",
"test1": "yarn build; chmod +x lib/bin/cli.js; yarn cli tests/test1.config.ts",
"test2": "yarn build; chmod +x lib/bin/cli.js; yarn cli tests/test2.config.ts",
"test3": "yarn build; chmod +x lib/bin/cli.js; yarn cli tests/test3.config.ts"
},
"files": [
"lib",
"src"
],
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
},
"devDependencies": {
"@kubekit/codegen": "^0.0.19",
"@kubekit/sync": "^0.0.21",
"@types/lodash.merge": "^4.6.9",
"@types/node": "^20.11.30",
"esbuild": "^0.21.1",
"esbuild-runner": "^2.2.2",
"openapi-types": "^12.1.3",
"typescript": "5.4.5"
},
"dependencies": {
"@kubekit/client": "0.0.23",
"commander": "^6.2.0",
"jsonpath-plus": "^9.0.0",
"lodash.merge": "^4.6.2"
}
}
244 changes: 244 additions & 0 deletions packages/kubekit-prepare/src/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
#!/usr/bin/env node

import program from 'commander'
import * as path from 'path'
import * as fs from 'fs/promises'
import { patchFunctions } from '../patchOpenApi'
import merge from 'lodash.merge'
import { OpenAPIV3 } from 'openapi-types'
import { type ResourceRules, inspectRules } from '../getRules'
import { HttpMethods, assertNotNull } from '../lib'
import { apiClient } from '@kubekit/client'
import { ConfigFile } from '../config'
import { readCoreV1NamespacedServiceAccount } from '../k8s-client/v1'
import { cleanOpenAPISchema } from '../cleanOpenAPISchema'

let ts = false
try {
if (require.resolve('esbuild') && require.resolve('esbuild-runner')) {
require('esbuild-runner/register')
}
ts = true
} catch {}

// tslint:disable-next-line
const meta = require('../../package.json')

program.version(meta.version).usage('</path/to/config.js>').parse(process.argv)

const configFilePath = program.args[0]

if (
program.args.length === 0 ||
!/\.(c?(jsx?|tsx?)|jsonc?)?$/.test(configFilePath)
) {
program.help()
} else {
if (/\.tsx?$/.test(configFilePath) && !ts) {
console.error(
'Encountered a TypeScript configfile, but neither esbuild-runner nor ts-node are installed.'
)
process.exit(1)
}
run(path.resolve(process.cwd(), configFilePath))
}
async function run(configFilePath: string) {
process.chdir(path.dirname(configFilePath))

const unparsedConfig = require(configFilePath)
const config: ConfigFile = unparsedConfig.default ?? unparsedConfig
if (config.kind === 'Group' || config.kind === 'User') {
console.error('Group and User are not yet supported. Please open an issue.')
process.exit(1)
}
if (config.kind === 'ServiceAccount') {
try {
await readCoreV1NamespacedServiceAccount({
name: config.name,
namespace: config.namespace,
})
} catch (err) {
console.error(
`[ERROR] ServiceAccount name: ${config.name} namespace: ${config.namespace} not found.`
)
process.exit(1)
}
}
try {
console.log(`Generating ${config.outputFile}`)
await generateOpenApi(config)
console.log(`Done`)
} catch (err) {
console.error(err)
process.exit(1)
}
}

async function generateOpenApi(config: ConfigFile) {
const [
{
resourceRules,
// TODO: Implement nonResourceRules as well
nonResourceRules: _,
},
rootOpenApiText,
] = await Promise.all([
inspectRules(config.kind, config.name, config.namespace),
apiClient<string>({ path: '/openapi/v3' }),
])

const source: OpenAPIV3.Document<{}> = JSON.parse(rootOpenApiText)

const cwd = process.cwd()

const sourcePaths = Object.keys(source.paths)
const docs: OpenAPIV3.Document<{}>[] = []

const addDoc = async (path: string, rule: ResourceRules[string]) => {
const sourceDoc = await apiClient<OpenAPIV3.Document<{}>>({
path: `/openapi/v3/${path}`,
})
const paths = Object.keys(sourceDoc.paths)
const resultDoc: OpenAPIV3.Document<{}> = {
...sourceDoc,
paths: {},
}

for (let [maybeResourceName, { verbs, namespaces }] of Object.entries(
rule
)) {
let [resourceName, subResourceName] = maybeResourceName.split('/')
// "/apis/networking.k8s.io/v1/namespaces/{namespace}/ingresses/{name}"
// "/apis/networking.k8s.io/v1/ingresses"
// "/apis/networking.k8s.io/v1/watch/ingresses"

if (resourceName === '*') {
resourceName = '.+'
}
const namespacedPaths: string[] = [
...(subResourceName
? [
`^/${path}/namespaces/{namespace}/${resourceName}/{name}/${subResourceName}$`,
`^/${path}/namespaces/{namespace}/${resourceName}/{name}/${subResourceName}/{.+}$`,
]
: [
// operation id: read | replace | delete | patch
// verb: "get" | "list" | "create" | "update" | "patch" | "delete"
`^/${path}/namespaces/{namespace}/${resourceName}/{name}$`,
// operation id: list | create | deletecollection
// verb: "deletecollection" | "proxy" | "watch"
`^/${path}/namespaces/{namespace}/${resourceName}$`,
]),
// /watch/はdeprecatedなので、無視します
]
const clusterPaths = subResourceName
? [
`^/${path}/${resourceName}$/{name}/${subResourceName}$`,
`^/${path}/${resourceName}$/{name}/${subResourceName}/{.+}$`,
]
: [`^/${path}/${resourceName}$`]

function isMatchedPath(fullPath: string): boolean {
if (namespaces[0] === '*') {
return (
clusterPaths.findIndex((path) =>
new RegExp(path).test(fullPath)
) !== -1 ||
namespacedPaths.findIndex((path) =>
new RegExp(path).test(fullPath)
) !== -1
)
}

return (
namespacedPaths.findIndex((path) =>
new RegExp(path).test(fullPath)
) !== -1
)
}
paths
.filter((path) => isMatchedPath(path))
.forEach((path) => {
// "get" | "list" | "watch" | "listwatch" | "create" | "update" | "patch" | "delete" | "*"
if (verbs[0] === '*') {
resultDoc.paths[path] = sourceDoc.paths[path]
} else {
for (const verb of verbs) {
const pathsObject = sourceDoc.paths[path]
if (pathsObject) {
for (const entry of Object.entries(pathsObject)) {
const [httpMethod, pathItemObject] = entry as [
HttpMethods,
any
]
if (pathItemObject['x-kubernetes-action'] === verb) {
if (!resultDoc.paths[path]) {
resultDoc.paths[path] = {}
}
const resultPathsObject = resultDoc.paths[path]
assertNotNull(resultPathsObject)
resultPathsObject[httpMethod] =
sourceDoc.paths[path]?.[httpMethod]
if (sourceDoc.paths[path]?.parameters) {
resultPathsObject.parameters =
sourceDoc.paths[path]?.parameters
}
}
}
}
}
}
})
}
docs.push(resultDoc)
}

const tasks: Promise<void>[] = []

const coreV1Rules = resourceRules[''] || resourceRules['*']
if (coreV1Rules) {
if (!sourcePaths.find((path) => path === 'api/v1')) {
throw Error('The coreV1 API was not found. This is an unexpected state.')
}

tasks.push(addDoc('api/v1', coreV1Rules))
delete resourceRules['']
}

for (let [apiGroup, rule] of Object.entries(resourceRules)) {
let matchPath = apiGroup
if (apiGroup === '*') {
matchPath = '.+'
}
const matchedApiGroups = sourcePaths.filter((path) =>
new RegExp(`^apis/${matchPath}/`).test(path)
)
if (matchedApiGroups.length === 0) {
throw Error(
`The API group '${apiGroup}' is not installed on the k8s cluster. Have you forgotten to install the CRD?`
)
}
matchedApiGroups.forEach((sourcePath) =>
tasks.push(addDoc(sourcePath, rule))
)
}

await Promise.all(tasks)

let mergedSwaggerFile = merge({}, ...docs)

for (const patchFunction of patchFunctions) {
mergedSwaggerFile = patchFunction(mergedSwaggerFile)
}

const swaggerFilePath = path.join(config.outputFile || cwd, 'swagger.json')

try {
fs.writeFile(
path.resolve(process.cwd(), config.outputFile),
JSON.stringify(cleanOpenAPISchema(mergedSwaggerFile), undefined, 2)
)
} catch (e) {
console.error(`Failed to write file: ${swaggerFilePath}`, e)
}
}
Loading

0 comments on commit 891504a

Please sign in to comment.