├── .gitmodules ├── .babelrc ├── Dockerfile ├── examples ├── guestbook-ts │ ├── .gitignore │ ├── vscode-autocompletion.png │ ├── tsconfig.json │ ├── package.json │ ├── guestbook.ts │ └── README.md └── short │ └── index.js ├── .gitignore ├── bin ├── version.js └── run-release.sh ├── .editorconfig ├── src ├── path.js ├── short │ ├── generate.js │ ├── index.js │ ├── transform.js │ └── kinds.js ├── overlay │ ├── index.js │ ├── data.js │ ├── generators.js │ └── compile.js ├── generate.js ├── validate.js ├── chart │ ├── values.js │ ├── template.js │ └── index.js ├── schema.js ├── resources.js ├── base64.js ├── transform │ └── index.js └── image-reference.ts ├── README.md ├── go.mod ├── tests ├── base64.test.js ├── schema.test.js ├── mock.js ├── chart_values.test.js ├── short.test.js ├── path.test.js ├── data.test.js ├── chart_templates.test.js ├── transforms.test.js ├── generators.test.js ├── generate.test.js ├── resources.test.js ├── image-reference.test.js ├── short_transforms.test.js └── overlay.test.js ├── tsconfig.json ├── cmd ├── apigen │ ├── templates │ │ ├── shapes.ts.mustache │ │ └── api.ts.mustache │ └── main.go └── dedup │ └── main.go ├── .eslintrc ├── RELEASING.md ├── .travis.yml ├── go.sum ├── Makefile ├── package.json ├── pkg └── gen │ ├── nodejs.go │ └── typegen.go └── LICENSE /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY @jkcfg/kubernetes /jk/modules/@jkcfg/kubernetes/ 3 | -------------------------------------------------------------------------------- /examples/guestbook-ts/.gitignore: -------------------------------------------------------------------------------- 1 | /guestbook.js 2 | /redis-master-deployment.yaml 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /@jkcfg 3 | /lib 4 | /src/api.ts 5 | /src/shapes.ts 6 | 7 | /examples/*/package-lock.json 8 | build/ 9 | -------------------------------------------------------------------------------- /examples/guestbook-ts/vscode-autocompletion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jkcfg/kubernetes/HEAD/examples/guestbook-ts/vscode-autocompletion.png -------------------------------------------------------------------------------- /bin/version.js: -------------------------------------------------------------------------------- 1 | // NB checks the version in the dist directory 2 | const pkg = require('../@jkcfg/kubernetes/package.json'); 3 | console.log(pkg.version); 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/path.js: -------------------------------------------------------------------------------- 1 | function basename(path) { 2 | return path.substring(path.lastIndexOf('/') + 1); 3 | } 4 | 5 | function dirname(path) { 6 | return path.substring(0, path.lastIndexOf('/')); 7 | } 8 | 9 | export { basename, dirname }; 10 | -------------------------------------------------------------------------------- /src/short/generate.js: -------------------------------------------------------------------------------- 1 | import { long } from './index'; 2 | import { valuesForGenerate } from '../generate'; 3 | 4 | export function generateFromShorts(shorts) { 5 | const longs = Promise.all(shorts.map(async (v) => { 6 | const s = await Promise.resolve(v); 7 | return long(s); 8 | })); 9 | return valuesForGenerate(longs); 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes module for [jk][] 2 | 3 | This is a module for [jk][] that helps generate [Kubernetes][] configuration. 4 | 5 | [![Build Status](https://travis-ci.org/jkcfg/kubernetes.svg?branch=master)][build-status] 6 | 7 | [jk]: https://github.com/jkcfg/jk 8 | [Kubernetes]: https://kubernetes.io/ 9 | [build-status]: https://travis-ci.org/jkcfg/kubernetes 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jkcfg/kubernetes 2 | 3 | require ( 4 | github.com/Masterminds/semver v1.5.0 5 | github.com/ahmetb/go-linq v3.0.0+incompatible 6 | github.com/cbroglie/mustache v1.0.1 7 | github.com/gogo/protobuf v1.2.0 // indirect 8 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3 9 | github.com/mitchellh/go-wordwrap v1.0.0 10 | k8s.io/apimachinery v0.0.0-20181220065808-98853ca904e8 11 | ) 12 | 13 | go 1.13 14 | -------------------------------------------------------------------------------- /examples/guestbook-ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "sourceMap": false, 7 | "stripInternal": true, 8 | "experimentalDecorators": true, 9 | "pretty": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strictNullChecks": true, 14 | "baseUrl": "../.." 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/short/index.js: -------------------------------------------------------------------------------- 1 | // "Short" forms for API objects. 2 | // 3 | // This is the inspiration and target format: https://docs.koki.io/short/ 4 | 5 | import kinds from './kinds'; 6 | 7 | // long takes a short description and turns it into a full API object. 8 | function long(obj) { 9 | const [kind] = Object.keys(obj); 10 | const tx = kinds[kind]; 11 | if (tx === undefined) { 12 | throw new Error(`unknown kind: ${kind}`); 13 | } 14 | return tx(obj[kind]); 15 | } 16 | 17 | export { long }; 18 | -------------------------------------------------------------------------------- /tests/base64.test.js: -------------------------------------------------------------------------------- 1 | import { encode, ascii2bytes } from '../src/base64'; 2 | 3 | test('empty -> empty', () => { 4 | expect(encode(new Uint8Array(0))).toEqual(''); 5 | }); 6 | 7 | test('ascii2bytes', () => { 8 | expect(ascii2bytes('ABC')).toEqual([65, 66, 67]); 9 | }); 10 | 11 | // from RFC 4648 12 | [ 13 | ['f', 'Zg=='], 14 | ["fo", "Zm8="], 15 | ["foo", "Zm9v"], 16 | ["foob", "Zm9vYg=="], 17 | ["fooba", "Zm9vYmE="], 18 | ["foobar", "Zm9vYmFy"], 19 | ].forEach(([input, output]) => { 20 | test(`'${input}' -> '${output}' `, () => { 21 | expect(encode(ascii2bytes(input))).toEqual(output); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "@jkcfg/kubernetes", 4 | "allowJs": true, 5 | "target": "es2017", 6 | "module": "es6", 7 | "moduleResolution": "node", 8 | "sourceMap": false, 9 | "stripInternal": true, 10 | "experimentalDecorators": true, 11 | "pretty": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": false, 14 | "noImplicitReturns": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strictNullChecks": true 17 | }, 18 | "include": [ 19 | "src" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "examples" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /examples/guestbook-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guestbook-ts", 3 | "version": "1.0.0", 4 | "description": "Kubernetes stateless example", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/jkcfg/kubernetes.git" 8 | }, 9 | "main": "guestbook.ts", 10 | "scripts": { 11 | "build": "tsc" 12 | }, 13 | "keywords": [ 14 | "kubernetes", 15 | "jk", 16 | "example" 17 | ], 18 | "author": "The jk Authors", 19 | "license": "Apache-2.0", 20 | "devDependencies": { 21 | "typescript": "^3.4.4" 22 | }, 23 | "dependencies": { 24 | "@jkcfg/std": "^0.2.3", 25 | "@jkcfg/kubernetes": "^0.2.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/schema.test.js: -------------------------------------------------------------------------------- 1 | import { schemaPath } from '../src/schema'; 2 | 3 | const k8s = 'v1.16.0'; // just for the sake of supplying something 4 | 5 | test('service v1', () => { 6 | expect(schemaPath(k8s, 'v1', 'Service')).toEqual(`${k8s}-local/service-v1.json`); 7 | }); 8 | 9 | test('deployment apps/v1', () => { 10 | expect(schemaPath(k8s, 'apps/v1', 'Deployment')).toEqual(`${k8s}-local/deployment-apps-v1.json`); 11 | }); 12 | 13 | test('customresourcedefinition apiextensions.k8s.io/v1beta1', () => { 14 | expect(schemaPath(k8s, 'apiextensions.k8s.io/v1beta1', 'CustomResourceDefinition')).toEqual(`${k8s}-local/customresourcedefinition-apiextensions-v1beta1.json`); 15 | }); 16 | -------------------------------------------------------------------------------- /src/overlay/index.js: -------------------------------------------------------------------------------- 1 | import * as std from '@jkcfg/std'; 2 | import { valuesForGenerate } from '../generate'; 3 | import { compile } from './compile'; 4 | 5 | // overlay is compile, specialised with the std lib functions. 6 | function overlay(path, config, opts = {}) { 7 | const compileStd = compile(std, opts); 8 | return compileStd(path, config); 9 | } 10 | 11 | function kustomization(path) { 12 | return overlay(path, { bases: [path] }, { file: 'kustomization.yaml' }); 13 | } 14 | 15 | function generateKustomization(path) { 16 | const resources = kustomization(path); 17 | return valuesForGenerate(resources); 18 | } 19 | 20 | export { overlay, kustomization, generateKustomization }; 21 | -------------------------------------------------------------------------------- /src/generate.js: -------------------------------------------------------------------------------- 1 | // Given an array (or a promise of an array) of Kubernetes resources, 2 | // return a list of values suitable for use with `jk generate` 3 | async function valuesForGenerate(resources, opts = {}) { 4 | const { prefix = '', namespaceDirs = true } = opts; 5 | const all = await Promise.resolve(resources); 6 | return all.map((r) => { 7 | const filename = `${r.metadata.name}-${r.kind.toLowerCase()}.yaml`; 8 | let path = filename; 9 | if (namespaceDirs && r.metadata.namespace) { 10 | path = `${r.metadata.namespace}/${filename}`; 11 | } 12 | if (prefix !== '') { 13 | path = `${prefix}/${path}`; 14 | } 15 | return { file: path, value: r }; 16 | }); 17 | } 18 | 19 | export { valuesForGenerate }; 20 | -------------------------------------------------------------------------------- /cmd/apigen/templates/shapes.ts.mustache: -------------------------------------------------------------------------------- 1 | // *** WARNING: this file was generated by the apigen generation tool. *** 2 | // *** Do not edit by hand unless you're certain you know what you are doing! *** 3 | 4 | {{#Groups}} 5 | export namespace {{Group}} { 6 | {{#Versions}} 7 | export namespace {{Version}} { 8 | {{#Kinds}} 9 | {{{Comment}}} 10 | export interface {{Kind}} { 11 | {{#RequiredProperties}} 12 | {{{Comment}}} 13 | {{Name}}: {{{PropType}}} 14 | 15 | {{/RequiredProperties}} 16 | {{#OptionalProperties}} 17 | {{{Comment}}} 18 | {{Name}}?: {{{PropType}}} 19 | 20 | {{/OptionalProperties}} 21 | } 22 | {{{TypeGuard}}} 23 | 24 | {{/Kinds}} 25 | } 26 | 27 | {{/Versions}} 28 | } 29 | 30 | {{/Groups}} 31 | -------------------------------------------------------------------------------- /src/validate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The validate module exports a default value for use with `jk 3 | * validate`. 4 | */ 5 | 6 | import * as param from '@jkcfg/std/param'; 7 | import { withModuleRef } from '@jkcfg/std/resource'; 8 | import { validateWithResource } from '@jkcfg/std/schema'; 9 | import { schemaPath } from './schema'; 10 | 11 | const defaultK8sVersion = 'v1.16.0'; 12 | 13 | export function validateSchema(k8sVersion, value) { 14 | const path = schemaPath(k8sVersion, value.apiVersion, value.kind); 15 | return withModuleRef(ref => validateWithResource(value, `schemas/${path}`, ref)); 16 | } 17 | 18 | export default function validate(value) { 19 | const k8sVersion = param.String('k8s-version', defaultK8sVersion); 20 | return validateSchema(k8sVersion, value); 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "rules": { 4 | "import/prefer-default-export": 0, 5 | "import/no-unresolved": [ 6 | 2, 7 | { 8 | "ignore": [ 9 | "^@jkcfg/std($|/)", 10 | "/?(api|shapes)$", 11 | ] 12 | } 13 | ], 14 | "no-bitwise": [ 15 | "error", {"allow": ["<<", ">>", "|", "&"]} 16 | ], 17 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 18 | "indent": ["error", 2, {"SwitchCase": 0, "CallExpression": {"arguments": "first"}}], 19 | "no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"], 20 | "object-curly-newline": ["error", { "ImportDeclaration": "never" }], 21 | "no-use-before-define": ["error", { "functions": false, "classes": true }] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/chart/values.js: -------------------------------------------------------------------------------- 1 | import { merge } from '@jkcfg/std/merge'; 2 | 3 | // Given a means of getting parameters, and a specification of the 4 | // Values (including their defaults), compile a struct of values for 5 | // instantiating a chart. To discriminate the values from other 6 | // parameters, an option is the prefix; by default, we expect the 7 | // command-line values to be passed as e.g., `-p values.image.tag=v1`, 8 | // and any file(s) (e.g., passed as `-f value.json`) to be of the form 9 | // 10 | // ``` 11 | // values: 12 | // image: 13 | // repository: helloworld 14 | // ``` 15 | const values = (param, opts = {}) => function compile(defaults) { 16 | const { prefix = 'values' } = opts; 17 | const commandLine = param.Object(prefix, {}); 18 | return merge(defaults, commandLine); 19 | }; 20 | 21 | export { values }; 22 | -------------------------------------------------------------------------------- /src/overlay/data.js: -------------------------------------------------------------------------------- 1 | // dataFromFiles reads the contents of each file given and returns a 2 | // Map of the filenames to file contents. 3 | async function dataFromFiles(readEncoded, files) { 4 | const result = new Map(); 5 | await Promise.all(files.map(f => readEncoded(f).then((c) => { 6 | result.set(f, c); 7 | }))); 8 | return result; 9 | } 10 | 11 | // Given `dir` and `read` procedures, construct a map of file basename 12 | // to file contents (as strings). 13 | const dataFromDir = ({ dir, read, Encoding }) => async function data(path) { 14 | const d = dir(path); 15 | const files = d.files.filter(({ isdir }) => !isdir).map(({ name }) => name); 16 | const readFile = f => read(`${path}/${f}`, { encoding: Encoding.String }); 17 | return dataFromFiles(readFile, files); 18 | }; 19 | 20 | export { dataFromFiles, dataFromDir }; 21 | -------------------------------------------------------------------------------- /examples/short/index.js: -------------------------------------------------------------------------------- 1 | // An example of using shortened forms to represent resources. 2 | 3 | import { valuesForGenerate } from '@jkcfg/kubernetes/generate'; 4 | import { generateFromShorts } from '@jkcfg/kubernetes/short/generate'; 5 | 6 | const deployment = { 7 | deployment: { 8 | name: 'foo-dep', 9 | namespace: 'foo-ns', 10 | pod_meta: { 11 | labels: { app: 'hello' }, 12 | }, 13 | recreate: false, 14 | replicas: 5, 15 | max_extra: 1, 16 | max_unavailable: 2, 17 | progress_deadline: 30, 18 | containers: [ 19 | { 20 | name: 'hello', 21 | image: 'helloworld', 22 | }, 23 | ], 24 | } 25 | }; 26 | 27 | const service = { 28 | service: { 29 | name: 'foo-svc', 30 | namespace: 'foo-ns', 31 | selector: { app: 'hello' }, 32 | }, 33 | }; 34 | 35 | export default generateFromShorts([deployment, service]); 36 | -------------------------------------------------------------------------------- /tests/mock.js: -------------------------------------------------------------------------------- 1 | const dir = dirs => path => { 2 | if (path in dirs) { 3 | return dirs[path]; 4 | } 5 | throw new Error(`path not found ${path}`); 6 | }; 7 | 8 | const read = files => async function r(path, { encoding }) { 9 | if (path in files) { 10 | const encodings = files[path]; 11 | if (encoding in encodings) { 12 | return encodings[encoding]; 13 | } 14 | throw new Error(`no value for encoding "${encoding}"`); 15 | } 16 | throw new Error(`file not found ${path}`); 17 | }; 18 | 19 | function fs(dirs, files) { 20 | return { 21 | dir: dir(dirs), 22 | read: read(files), 23 | Encoding: Encoding, 24 | }; 25 | } 26 | 27 | // It's not important what the values are, just that we use them 28 | // consistently. 29 | const Encoding = Object.freeze({ 30 | String: 'string', 31 | JSON: 'json', 32 | Bytes: 'bytes', 33 | }); 34 | 35 | export { fs, Encoding }; 36 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing `@jkcfg/kubernetes` 2 | 3 | Steps to produce a new `@jkcfg/kubernetes` version: 4 | 5 | 1. Make sure you are on master, and that it has the latest from 6 | `origin/master`: 7 | 8 | ```console 9 | $ git checkout master 10 | $ git pull --ff-only origin master 11 | ``` 12 | 13 | If git complains that it can't fast-forward, you've probably 14 | committed something to master by mistake. Consider putting it in a 15 | branch to submit as a PR. 16 | 17 | 2. Bump the version in `package{-lock}.json`, commit the result. 18 | 19 | 3. Tag the commit and push it (with the new commit): 20 | 21 | ```console 22 | $ git tag -a x.y.z -m "x.y.z release" 23 | $ git push --tags origin master 24 | ``` 25 | 26 | 4. Wait for the CI build to push the NPM module 27 | 28 | 5. Go and edit the release page on GitHub with the list of API 29 | changes, new features and fixes. 30 | -------------------------------------------------------------------------------- /tests/chart_values.test.js: -------------------------------------------------------------------------------- 1 | import { values } from '../src/chart/values'; 2 | 3 | function params(obj = {}) { 4 | return { Object: (k, d) => (obj.hasOwnProperty(k)) ? obj[k] : d }; 5 | } 6 | 7 | test('empty values and empty defaults', () => { 8 | const vals = values(params({})); 9 | expect(vals({})).toEqual({}); 10 | }); 11 | 12 | test('defaults', () => { 13 | const vals = values(params({})); 14 | expect(vals({app: 'foo'})).toEqual({app: 'foo'}); 15 | }); 16 | 17 | test('defaults and values', () => { 18 | const commandLine = {image: {repository: 'helloworld'}}; 19 | const vals = values(params({values: commandLine})); 20 | expect(vals({image: {tag: 'v1'}})).toEqual({ 21 | image: { 22 | repository: 'helloworld', 23 | tag: 'v1', 24 | } 25 | }); 26 | }); 27 | 28 | test('values override defaults', () => { 29 | const vals = values(params({values: {app: 'bar'}})); 30 | expect(vals({app: 'foo'})).toEqual({app: 'bar'}); 31 | }); 32 | -------------------------------------------------------------------------------- /src/chart/template.js: -------------------------------------------------------------------------------- 1 | const isTemplateFile = info => (!info.isDir && info.name.endsWith('.yaml')); 2 | 3 | // { readString, compile } -> filename -> Promise (values -> resource) 4 | const loadTemplate = ({ readString, parse, compile }) => async function load(path) { 5 | const file = await readString(path); 6 | const template = compile(file); 7 | return values => parse(template({ values })); 8 | }; 9 | 10 | function flatMap(fn, array) { 11 | return [].concat(...array.map(fn)); 12 | } 13 | 14 | // { readString, compile, dir } -> values -> Promise [string] 15 | const loadDir = ({ readString, parse, compile, dir }, path = 'templates') => async function templates(values) { 16 | const load = loadTemplate({ readString, compile, parse }); 17 | const d = dir(path); 18 | const loadTempl = info => load(info.path); 19 | const allTempl = await Promise.all(d.files.filter(isTemplateFile).map(loadTempl)); 20 | return flatMap(t => t(values), allTempl); 21 | }; 22 | 23 | export { loadDir, loadTemplate }; 24 | -------------------------------------------------------------------------------- /tests/short.test.js: -------------------------------------------------------------------------------- 1 | import { long } from '../src/short'; 2 | import { apps, core } from '../src/api'; 3 | 4 | // Add to this as the spec gets more complete ... 5 | test('deployment', () => { 6 | const dep = { 7 | deployment: { 8 | name: 'foo-dep', 9 | namespace: 'foo-ns', 10 | labels: { app: 'foo' }, 11 | } 12 | }; 13 | expect(long(dep)).toEqual(new apps.v1.Deployment('foo-dep', { 14 | metadata: { 15 | namespace: 'foo-ns', 16 | labels: { app: 'foo' }, 17 | }, 18 | })); 19 | }); 20 | 21 | test('service', () => { 22 | const svc = { 23 | service: { 24 | name: 'bar-svc', 25 | namespace: 'bar-ns', 26 | type: 'node-port', 27 | selector: { app: 'bar' }, 28 | } 29 | }; 30 | expect(long(svc)).toEqual(new core.v1.Service('bar-svc', { 31 | metadata: { 32 | namespace: 'bar-ns', 33 | name: 'bar-svc', 34 | }, 35 | spec: { 36 | type: 'NodePort', 37 | selector: { app: 'bar' } 38 | } 39 | })); 40 | }); 41 | -------------------------------------------------------------------------------- /examples/guestbook-ts/guestbook.ts: -------------------------------------------------------------------------------- 1 | import * as std from '@jkcfg/std'; 2 | import * as k8s from '@jkcfg/kubernetes/api'; 3 | 4 | const redisLabels = { 5 | app: 'redis', 6 | role: 'master', 7 | tier: 'backend', 8 | }; 9 | 10 | const deployment = new k8s.apps.v1.Deployment('redis-master', { 11 | metadata: { 12 | labels: { 13 | app: 'redis', 14 | }, 15 | }, 16 | spec: { 17 | selector: { 18 | matchLabels: redisLabels, 19 | }, 20 | replicas: 1, 21 | template: { 22 | metadata: { 23 | labels: redisLabels, 24 | }, 25 | spec: { 26 | containers: [{ 27 | name: 'master', 28 | image: 'redis', 29 | resources: { 30 | requests: { 31 | cpu: '100m', 32 | memory: '100Mi', 33 | } 34 | }, 35 | ports: [{ 36 | containerPort: 6379, 37 | }], 38 | }], 39 | }, 40 | }, 41 | } 42 | }); 43 | 44 | std.write(deployment, 'redis-master-deployment.yaml'); 45 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The schema module calculates access to Kubernetes JSON Schema 3 | * definitions. It's kept separate from validate.js so that only one 4 | * is tied to the jk runtime (by using resources). 5 | */ 6 | 7 | // schemaPath returns the path to the schema for a given Kubernetes 8 | // object, starting from wherever schemas are kept. 9 | export function schemaPath(k8sVersion, apiVersion, kind) { 10 | // this reproduces the logic from the tool that generates the 11 | // schemas: 12 | // 13 | // https://github.com/instrumenta/openapi2jsonschema/blob/master/openapi2jsonschema/command.py#L132 14 | // 15 | // and ff. 16 | const groupVersion = apiVersion.split('/'); 17 | // 'apps/v1' vs 'v1' 18 | if (groupVersion.length > 1) { 19 | // 'apiextensions.k8s.io' vs 'apps' 20 | const [group] = groupVersion[0].split('.'); 21 | groupVersion[0] = group; 22 | } 23 | const kindGroupVersion = [kind, ...groupVersion]; 24 | return `${k8sVersion}-local/${kindGroupVersion.join('-').toLowerCase()}.json`; 25 | } 26 | -------------------------------------------------------------------------------- /src/resources.js: -------------------------------------------------------------------------------- 1 | // traverse returns the value found by successively indexing elements 2 | // of `path`, or `def` if any are not present. 3 | function traverse(path, object, def) { 4 | let obj = object; 5 | for (const elem of path) { 6 | if (!Object.prototype.hasOwnProperty.call(obj, elem)) { 7 | return def; 8 | } 9 | obj = obj[elem]; 10 | } 11 | return obj; 12 | } 13 | 14 | // Yields all the container definitions used in a resource. 15 | function* iterateContainers(resource) { 16 | // this is mildly hacky; check for the usual places that image refs 17 | // are found 18 | for (const path of [ 19 | ['spec', 'template', 'spec', 'initContainers'], 20 | ['spec', 'template', 'spec', 'containers'], 21 | ['spec', 'jobTemplate', 'spec', 'template', 'spec', 'initContainers'], 22 | ['spec', 'jobTemplate', 'spec', 'template', 'spec', 'containers'], 23 | ]) { 24 | const containers = traverse(path, resource, []); 25 | for (let i = 0; i < containers.length; i++) { 26 | yield containers[i]; 27 | } 28 | } 29 | } 30 | 31 | export { iterateContainers }; 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" # always use the latest; we are only using it as a test runner for now 4 | cache: npm 5 | 6 | env: 7 | global: 8 | - GOVERSION=1.13.8 9 | - GOPATH= 10 | - GOROOT= 11 | - PATH=~/go-${GOVERSION}/bin:~/go/bin:$PATH 12 | 13 | before_install: 14 | - (cd ~ && curl -fsSLO https://dl.google.com/go/go${GOVERSION}.linux-amd64.tar.gz) 15 | - mkdir ~/go-${GOVERSION} && tar xf ~/go${GOVERSION}.linux-amd64.tar.gz -C ~/go-${GOVERSION} --strip-components 1 16 | 17 | services: 18 | - docker 19 | 20 | jobs: 21 | include: 22 | - stage: Tests 23 | name: lint 24 | script: npm run lint 25 | 26 | - name: unit tests 27 | script: make test 28 | 29 | # Run make dist and build-image _without_ pushing results, if no tag 30 | - name: dist 31 | script: make dist build-image 32 | if: tag IS blank 33 | 34 | # Run make dist and push stuff 35 | - stage: Deploy 36 | script: 37 | # build and publish 38 | - make dist build-image 39 | - ./bin/run-release.sh $TRAVIS_TAG 40 | if: tag IS present 41 | -------------------------------------------------------------------------------- /tests/path.test.js: -------------------------------------------------------------------------------- 1 | import { basename, dirname } from '../src/path'; 2 | 3 | test('basename of bare filename is the filename', () => { 4 | expect(basename('foo.txt')).toEqual('foo.txt'); 5 | }); 6 | 7 | test('basename of apparent dir is empty', () => { 8 | expect(basename('foo/')).toEqual(''); 9 | }); 10 | 11 | test('basename of file in a few nested directories', () => { 12 | expect(basename('./foo/bar/baz.config/conf.txt')).toEqual('conf.txt'); 13 | }); 14 | 15 | test('basename in current dir', () => { 16 | expect(basename('./foo.txt')).toEqual('foo.txt'); 17 | }); 18 | 19 | test('lack of extension does not affect basename', () => { 20 | expect(basename('./foo')).toEqual('foo'); 21 | }); 22 | 23 | test('dirname of filename is empty', () => { 24 | expect(dirname('foo')).toEqual(''); 25 | }); 26 | 27 | test('dirname of root is empty', () => { 28 | expect(dirname('/')).toEqual(''); 29 | }); 30 | 31 | test('dirname of single dir-looking thing', () => { 32 | expect(dirname('foo/')).toEqual('foo'); 33 | }); 34 | 35 | test('dirname with file at the end', () => { 36 | expect(dirname('foo/bar/baz')).toEqual('foo/bar'); 37 | }); 38 | 39 | test('dirname of current dir', () => { 40 | expect(dirname('./')).toEqual('.'); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/data.test.js: -------------------------------------------------------------------------------- 1 | import { dataFromFiles, dataFromDir } from '../src/overlay/data'; 2 | 3 | const foo = `--- 4 | conf: 5 | config1: 1 6 | config2: 2 7 | `; 8 | 9 | const bar = `--- 10 | stuff: 11 | - 1 12 | - 2 13 | - 3 14 | `; 15 | 16 | import { fs, Encoding } from './mock'; 17 | 18 | const { dir, read } = fs({ 19 | 'config': { 20 | files: [ 21 | {name: 'foo.yaml', isdir: false}, 22 | {name: 'bar.yaml', isdir: false}, 23 | {name: 'baz', isdir: true} 24 | ] 25 | }, 26 | }, { 27 | 'config/foo.yaml': { string: foo }, 28 | 'config/bar.yaml': { string: bar }, 29 | }); 30 | 31 | test('data from files', () => { 32 | const files = ['config/foo.yaml', 'config/bar.yaml'] 33 | const readFile = f => read(f, { encoding: Encoding.String }); 34 | expect.assertions(1); 35 | dataFromFiles(readFile, files).then(v => { 36 | expect(v).toEqual(new Map([ 37 | ['config/foo.yaml', foo], 38 | ['config/bar.yaml', bar], 39 | ])); 40 | }); 41 | }); 42 | 43 | test('generate data from dir', () => { 44 | expect.assertions(1); 45 | const data = dataFromDir({ dir, read, Encoding }); 46 | return data('config').then(d => expect(d).toEqual(new Map([ 47 | ['foo.yaml', foo], 48 | ['bar.yaml', bar], 49 | ]))); 50 | }); 51 | -------------------------------------------------------------------------------- /cmd/apigen/templates/api.ts.mustache: -------------------------------------------------------------------------------- 1 | // *** WARNING: this file was generated by the apigen generation tool. *** 2 | // *** Do not edit by hand unless you're certain you know what you are doing! *** 3 | 4 | {{#Groups}} 5 | export namespace {{Group}} { 6 | {{#Versions}} 7 | export namespace {{Version}} { 8 | {{#Kinds}} 9 | {{{Comment}}} 10 | export class {{Kind}} { 11 | {{#RequiredProperties}} 12 | {{{Comment}}} 13 | public {{Name}}: {{{PropType}}}; 14 | 15 | {{/RequiredProperties}} 16 | 17 | {{#OptionalProperties}} 18 | {{{Comment}}} 19 | public {{Name}}?: {{{PropType}}}; 20 | 21 | {{/OptionalProperties}} 22 | 23 | {{#HasMeta}} 24 | /** 25 | * Create a {{Group}}.{{Version}}.{{Kind}} object with the given unique name and description. 26 | * 27 | * @param name The _unique_ name of the object. 28 | * @param desc The description to use to populate this object properties. 29 | */ 30 | constructor(name: string, desc: {{Group}}.{{Version}}.{{Kind}}) { 31 | {{#Properties}} 32 | this.{{Name}} = {{{DefaultValue}}}; 33 | {{/Properties}} 34 | } 35 | {{/HasMeta}} 36 | } 37 | 38 | {{/Kinds}} 39 | } 40 | 41 | {{/Versions}} 42 | } 43 | 44 | {{/Groups}} 45 | -------------------------------------------------------------------------------- /bin/run-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | # Assumes `make dist build-image` has been run, meaning the package 7 | # code is all in ./@jkcfg/kubernetes and the image is in local docker. 8 | 9 | tag=$1 10 | user=jkcfg 11 | repo=kubernetes 12 | pkg=github.com/$user/$repo 13 | 14 | ## If needed, get from jkcfg/jk 15 | # function docker_run() { ... } 16 | 17 | ## if needed again, get from jkcfg/jk 18 | # function upload() { ... } 19 | 20 | echo "==> Checking package.json is up to date" 21 | version=$(node ./bin/version.js) 22 | if [ "$version" != "$tag" ]; then 23 | echo "error: releasing $tag but package.json references $version" 24 | exit 1 25 | fi 26 | 27 | echo "==> Pushing Docker image" 28 | if [ -z "$DOCKER_TOKEN" ]; then 29 | echo "error: DOCKER_TOKEN needs to be defined for pushing a Docker image" 30 | exit 1 31 | fi 32 | docker tag jkcfg/kubernetes "jkcfg/kubernetes:$tag" 33 | echo "$DOCKER_TOKEN" | docker login -u jkcfgbot --password-stdin 34 | docker push "jkcfg/kubernetes:$tag" 35 | 36 | echo "==> Uploading npm module" 37 | if [ -z "$NPM_TOKEN" ]; then 38 | echo "error: NPM_TOKEN needs to be defined for pushing npm modules" 39 | exit 1 40 | fi 41 | echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}' > @jkcfg/kubernetes/.npmrc 42 | (cd @jkcfg/kubernetes; npm publish) 43 | -------------------------------------------------------------------------------- /tests/chart_templates.test.js: -------------------------------------------------------------------------------- 1 | import { loadDir } from '../src/chart/template'; 2 | import handlebars from 'handlebars/lib/handlebars'; 3 | 4 | const compile = handlebars.compile; 5 | 6 | const dir = (path) => { 7 | if (path === 'templates') { 8 | return { 9 | path, 10 | files: [ 11 | { path: `templates/foo.yaml`, isDir: false, name: 'foo.yaml' }, 12 | { path: `templates/bar.yaml`, isDir: false, name: 'bar.yaml' }, 13 | ], 14 | }; 15 | } 16 | throw new Error(`dir ${path} does not exist`); 17 | }; 18 | 19 | const fooYAML = ` 20 | This is just some text. 21 | `; 22 | 23 | const barYAML = ` 24 | This is some text with a {{ values.variable }} reference. 25 | `; 26 | 27 | const readString = (path) => { 28 | switch (path) { 29 | case 'templates/foo.yaml': 30 | return fooYAML; 31 | case 'templates/bar.yaml': 32 | return barYAML; 33 | } 34 | throw new Error(`file ${path} not found`); 35 | }; 36 | 37 | test('load a dir of templates', () => { 38 | const templates = loadDir({ dir, readString, compile, parse: s => [s] }); 39 | const out = templates({ variable: 'handlebars' }); 40 | expect.assertions(2); 41 | return out.then(([foo, bar]) => { 42 | expect(foo).toEqual(fooYAML); 43 | expect(bar.trim()).toEqual('This is some text with a handlebars reference.'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 2 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 3 | github.com/ahmetb/go-linq v3.0.0+incompatible h1:qQkjjOXKrKOTy83X8OpRmnKflXKQIL/mC/gMVVDMhOA= 4 | github.com/ahmetb/go-linq v3.0.0+incompatible/go.mod h1:PFffvbdbtw+QTB0WKRP0cNht7vnCfnGlEpak/DVg5cY= 5 | github.com/cbroglie/mustache v1.0.1 h1:ivMg8MguXq/rrz2eu3tw6g3b16+PQhoTn6EZAhst2mw= 6 | github.com/cbroglie/mustache v1.0.1/go.mod h1:R/RUa+SobQ14qkP4jtx5Vke5sDytONDQXNLPY/PO69g= 7 | github.com/gogo/protobuf v1.2.0 h1:xU6/SpYbvkNYiptHJYEDRseDLvYE7wSqhYYNy0QSUzI= 8 | github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 9 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3 h1:sHsPfNMAG70QAvKbddQ0uScZCHQoZsT5NykGRCeeeIs= 10 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= 11 | github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= 12 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 13 | k8s.io/apimachinery v0.0.0-20181220065808-98853ca904e8 h1:WLypux0abPAfOJJKJNA1+g5yphAOk+ESOeSqWMwMnqA= 14 | k8s.io/apimachinery v0.0.0-20181220065808-98853ca904e8/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= 15 | -------------------------------------------------------------------------------- /examples/guestbook-ts/README.md: -------------------------------------------------------------------------------- 1 | # A simple Kubernetes example 2 | 3 | To run this example: 4 | 5 | ```console 6 | # Install the required dependencies: `typescript`, `@jkcfg/kubernetes`, ... 7 | $ npm install 8 | 9 | # Transpile typescript to javascript 10 | $ npx tsc 11 | 12 | # Run jk on the resulting javascript 13 | $ jk run -v guestbook.js 14 | wrote redis-master-deployment.yaml 15 | ``` 16 | 17 | `redis-master-deployment.yaml` should have been produced: 18 | 19 | ```yaml 20 | apiVersion: apps/v1 21 | kind: Deployment 22 | metadata: 23 | labels: 24 | app: redis 25 | name: redis-master 26 | spec: 27 | replicas: 1 28 | selector: 29 | matchLabels: 30 | app: redis 31 | role: master 32 | tier: backend 33 | template: 34 | metadata: 35 | labels: 36 | app: redis 37 | role: master 38 | tier: backend 39 | spec: 40 | containers: 41 | - image: redis 42 | name: master 43 | ports: 44 | - containerPort: 6379 45 | resources: 46 | requests: 47 | cpu: 100m 48 | memory: 100Mi 49 | ``` 50 | 51 | ## Visual Studio Code support 52 | 53 | Visual Studio Code supports typescript natively. `@jkcfg/kubernetes` provides 54 | types for Kubernetes objects, allowing type-aware autocompletion and field 55 | validation. For example this is vscode showing autocompletion for the 56 | `PodSpec` object. 57 | 58 | ![vscode autocompletion](vscode-autocompletion.png) 59 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | KUBE_SCHEMA_ORG:=yannh 2 | KUBE_SCHEMA_REPO:=kubernetes-json-schema 3 | KUBE_SCHEMA_SHA1:=23cab9da14079ad764032a4b1c6efaef6739d24b 4 | SCHEMA_DIR:=build/raw/${KUBE_SCHEMA_REPO}-${KUBE_SCHEMA_SHA1} 5 | COPIED_MARK:=build/.copied.${KUBE_SCHEMA_SHA1} 6 | 7 | .PHONY: all dist clean gen test copy-schemas 8 | 9 | all: gen 10 | 11 | gen: 12 | GO111MODULE=on go run ./cmd/apigen/ cmd/apigen/specs/swagger-v1.13.0.json cmd/apigen/templates ./src/ 13 | 14 | src/api.ts: gen 15 | src/shapes.ts: gen 16 | 17 | dist: src/api.ts src/shapes.ts ${COPIED_MARK} 18 | npx tsc 19 | npx tsc -d --emitDeclarationOnly --allowJs false 20 | cp README.md LICENSE package.json @jkcfg/kubernetes 21 | cp -R build/schemas @jkcfg/kubernetes/ 22 | 23 | clean: 24 | rm -rf @jkcfg 25 | rm -f src/api.ts src/shapes.ts 26 | rm -rf ./build 27 | 28 | test: gen 29 | npm test 30 | npm run lint 31 | 32 | ${COPIED_MARK}: ${SCHEMA_DIR} 33 | rm -rf ./build/schemas 34 | GO111MODULE=on go run ./cmd/dedup/ ${SCHEMA_DIR} ./build/schemas 35 | touch ${COPIED_MARK} 36 | 37 | build-image: dist 38 | mkdir -p build/image 39 | cp -R @jkcfg build/image/ 40 | docker build -t jkcfg/kubernetes -f Dockerfile build/image 41 | 42 | ${SCHEMA_DIR}: 43 | mkdir -p build 44 | git clone --depth 1 --no-checkout "https://github.com/${KUBE_SCHEMA_ORG}/${KUBE_SCHEMA_REPO}" ${SCHEMA_DIR} 45 | cd ${SCHEMA_DIR} && \ 46 | git sparse-checkout init --cone && \ 47 | git ls-tree -d -r HEAD --name-only | grep -E "v.*-local" | xargs git sparse-checkout add && \ 48 | git read-tree -mu HEAD 49 | -------------------------------------------------------------------------------- /src/chart/index.js: -------------------------------------------------------------------------------- 1 | import { dir } from '@jkcfg/std/fs'; 2 | import { read, parse, Encoding, Format } from '@jkcfg/std'; 3 | 4 | import { values } from './values'; 5 | import { loadDir } from './template'; 6 | import { valuesForGenerate } from '../generate'; 7 | 8 | function chart(resourcesFn, defaults, paramMod) { 9 | // lift defaults into Promise, since it may or may not be one 10 | const vals = Promise.resolve(defaults).then(values(paramMod)); 11 | return vals.then(resourcesFn); 12 | } 13 | 14 | function generateChart(resourcesFn, defaults, paramMod) { 15 | return chart(resourcesFn, defaults, paramMod).then(valuesForGenerate); 16 | } 17 | 18 | const readStr = path => read(path, { encoding: Encoding.String }); 19 | const parseYAML = str => parse(str, Format.YAMLStream); 20 | 21 | // loadTemplates :: (string -> template, path) -> values -> Promise [resource] 22 | function loadTemplates(compile, path = 'templates') { 23 | return loadDir({ compile, parse: parseYAML, readString: readStr, dir }, path); 24 | } 25 | 26 | // loadModuleTemplates :: (string -> template, , path) -> 27 | // values -> Promise [resources] 28 | function loadModuleTemplates(compile, resources, path = 'templates') { 29 | const readModuleStr = p => resources.read(p, { encoding: Encoding.String }); 30 | const funcs = { compile, parse: parseYAML, readString: readModuleStr, dir: resources.dir }; 31 | return loadDir(funcs, path); 32 | } 33 | 34 | export { chart, generateChart, loadTemplates, loadModuleTemplates }; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jkcfg/kubernetes", 3 | "version": "0.6.2", 4 | "author": "The jk Authors", 5 | "bugs": { 6 | "url": "https://github.com/jkcfg/kubernetes/issues" 7 | }, 8 | "dependencies": { 9 | "@jkcfg/std": "^0.3.2" 10 | }, 11 | "description": "jk Kubernetes library", 12 | "devDependencies": { 13 | "babel-jest": "^23.6.0", 14 | "babel-preset-env": "^1.7.0", 15 | "eslint": "^5.10.0", 16 | "eslint-config-airbnb-base": "^13.1.0", 17 | "eslint-plugin-import": "^2.14.0", 18 | "jest": "^23.6.0", 19 | "ts-jest": "^23.10.5", 20 | "typescript": "^3.2.2", 21 | "handlebars": "^4.1.1" 22 | }, 23 | "homepage": "https://github.com/jkcfg/kubernetes#readme", 24 | "keywords": [ 25 | "configuration", 26 | "code", 27 | "generation" 28 | ], 29 | "license": "Apache-2.0", 30 | "module": "kubernetes.js", 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/jkcfg/kubernetes.git" 34 | }, 35 | "scripts": { 36 | "build": "make", 37 | "lint": "eslint src", 38 | "test": "jest" 39 | }, 40 | "jest": { 41 | "preset": "ts-jest/presets/js-with-babel", 42 | "globals": { 43 | "ts-jest": { 44 | "diagnostics": { 45 | "ignoreCodes": [ 46 | 151001 47 | ] 48 | } 49 | } 50 | }, 51 | "testMatch": [ 52 | "/tests/*.test.js" 53 | ], 54 | "transformIgnorePatterns": [ 55 | "/@jkcfg/" 56 | ], 57 | "modulePathIgnorePatterns": [ 58 | "/@jkcfg/" 59 | ], 60 | "moduleDirectories": [ 61 | "/node_modules" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/base64.js: -------------------------------------------------------------------------------- 1 | const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; 2 | 3 | const chars = alphabet.split(''); 4 | 5 | function encode(bytes) { 6 | const triples = Math.floor(bytes.length / 3); 7 | const tripleLen = triples * 3; 8 | let encoded = ''; 9 | for (let i = 0; i < tripleLen; i += 3) { 10 | const bits = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; 11 | const c1 = chars[bits >> 18]; 12 | const c2 = chars[(bits >> 12) & 0x3f]; 13 | const c3 = chars[(bits >> 6) & 0x3f]; 14 | const c4 = chars[bits & 0x3f]; 15 | encoded = `${encoded}${c1}${c2}${c3}${c4}`; 16 | } 17 | switch (bytes.length - tripleLen) { 18 | case 1: { 19 | // left with 8 bits; pad to 12 bits to get two six-bit characters 20 | const last = bytes[bytes.length - 1]; 21 | const c1 = chars[last >> 2]; 22 | const c2 = chars[(last & 0x03) << 4]; 23 | encoded = `${encoded}${c1}${c2}==`; 24 | break; 25 | } 26 | case 2: { 27 | // left with 16 bits; pad to 18 bits to get three six-bit characters 28 | const last2 = (bytes[bytes.length - 2] << 10) | (bytes[bytes.length - 1] << 2); 29 | const c1 = chars[last2 >> 12]; 30 | const c2 = chars[(last2 >> 6) & 0x3f]; 31 | const c3 = chars[last2 & 0x3f]; 32 | encoded = `${encoded}${c1}${c2}${c3}=`; 33 | break; 34 | } 35 | default: 36 | break; 37 | } 38 | return encoded; 39 | } 40 | 41 | // Encode a native string (UTF16) of ASCII characters as an array of UTF8 bytes. 42 | function ascii2bytes(str) { 43 | const result = new Array(str.length); 44 | for (let i = 0; i < str.length; i += 1) { 45 | result[i] = str.charCodeAt(i); 46 | } 47 | return result; 48 | } 49 | 50 | export { encode, ascii2bytes }; 51 | -------------------------------------------------------------------------------- /tests/transforms.test.js: -------------------------------------------------------------------------------- 1 | import { patchResource, rewriteImageRefs } from '../src/transform'; 2 | import { apps } from '../src/api'; 3 | 4 | // for the match predicate 5 | const template = { 6 | apiVersion: 'apps/v1', 7 | kind: 'Deployment', 8 | metadata: { 9 | name: 'dep1', 10 | namespace: 'foo-ns', 11 | }, 12 | }; 13 | 14 | const resource = { 15 | ...template, 16 | spec: { 17 | replicas: 1, 18 | containers: [ 19 | {name: 'foo', image: 'foo:v1'}, 20 | ], 21 | } 22 | }; 23 | 24 | test('matches nothing', () => { 25 | const p = patchResource({}); 26 | expect(p(resource)).toEqual(resource); 27 | }); 28 | 29 | test('patch something', () => { 30 | // to make a patch, use the template so it matches, then add a field 31 | // to be changed. 32 | const templ = {...template}; 33 | templ.spec = {replicas: 6}; 34 | const p = patchResource(templ); 35 | 36 | // the expected result will be the resource, but with the 37 | // spec.replicas field changed. NB we have to be careful to clone 38 | // the spec, rather than assigning into it. 39 | const expected = {...resource}; 40 | expected.spec = {...resource.spec, replicas: 6}; 41 | expect(p(resource)).toEqual(expected); 42 | }); 43 | 44 | test('naive rewriteImageRefs', () => { 45 | const dep = new apps.v1.Deployment('foo', { 46 | metadata: { 47 | namespace: 'foons', 48 | }, 49 | spec: { 50 | template: { 51 | spec: { 52 | initContainers: [ 53 | { 54 | name: 'c1', 55 | image: 'foo:v1', 56 | } 57 | ], 58 | }, 59 | }, 60 | }, 61 | }); 62 | const dep2 = rewriteImageRefs(_ => 'bar:v1')(dep); 63 | expect(dep2.spec.template.spec.initContainers[0].image).toEqual('bar:v1'); 64 | }); 65 | -------------------------------------------------------------------------------- /pkg/gen/nodejs.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016-2018, Pulumi Corporation. 2 | // Copyright 2018, The jk Authors. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package gen 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/cbroglie/mustache" 22 | ) 23 | 24 | // -------------------------------------------------------------------------- 25 | 26 | // Main interface. 27 | 28 | // -------------------------------------------------------------------------- 29 | 30 | // NodeJSClient will generate a Pulumi Kubernetes provider client SDK for nodejs. 31 | func NodeJSClient( 32 | swagger map[string]interface{}, templateDir string, 33 | ) (inputsts, providerts string, err error) { 34 | definitions := swagger["definitions"].(map[string]interface{}) 35 | 36 | groupsSlice := createGroups(definitions, shapesOpts()) 37 | inputsts, err = mustache.RenderFile(fmt.Sprintf("%s/shapes.ts.mustache", templateDir), 38 | map[string]interface{}{ 39 | "Groups": groupsSlice, 40 | }) 41 | if err != nil { 42 | return "", "", err 43 | } 44 | 45 | groupsSlice = createGroups(definitions, apiOpts()) 46 | providerts, err = mustache.RenderFile(fmt.Sprintf("%s/api.ts.mustache", templateDir), 47 | map[string]interface{}{ 48 | "Groups": groupsSlice, 49 | }) 50 | if err != nil { 51 | return "", "", err 52 | } 53 | 54 | return inputsts, providerts, nil 55 | } 56 | -------------------------------------------------------------------------------- /tests/generators.test.js: -------------------------------------------------------------------------------- 1 | import { generateConfigMap, generateSecret } from '../src/overlay/generators'; 2 | import { core } from '../src/api'; 3 | import { fs } from './mock'; 4 | 5 | test('empty configmap', () => { 6 | expect.assertions(1); 7 | const gen = generateConfigMap((f, { encoding }) => { 8 | throw new Error('unexpected read of ${f}'); 9 | }); 10 | return gen({name: 'foo-conf'}).then((v) => { 11 | expect(v).toEqual(new core.v1.ConfigMap('foo-conf', { data: {} })); 12 | }) 13 | }); 14 | 15 | test('files and literals', () => { 16 | const { read } = fs({}, { 17 | 'config/foo.yaml': { string: 'foo: bar' }, 18 | }); 19 | const readStr = f => read(f, { encoding: 'string' }); 20 | const gen = generateConfigMap(readStr); 21 | const conf = { 22 | name: 'foo-conf', 23 | files: ['config/foo.yaml'], 24 | literals: ['some.property=some.value'], 25 | }; 26 | expect.assertions(1); 27 | return gen(conf).then((v) => { 28 | expect(v).toEqual(new core.v1.ConfigMap('foo-conf', { 29 | data: { 30 | 'foo.yaml': 'foo: bar', 31 | 'some.property': 'some.value', 32 | } 33 | })); 34 | }); 35 | }); 36 | 37 | test('secret from literal', () => { 38 | // this relies on a known base64 encoding: 39 | // 'foobar' -> 'Zm9vYmFy' (NB no trailing newline) 40 | const foobar = 'foobar'; 41 | const foobarEncoded = 'Zm9vYmFy'; 42 | const read = () => { 43 | return Promise.resolve(new Uint8Array([ 102, 111, 111, 98, 97, 114 ])); 44 | }; 45 | const gen = generateSecret(read); 46 | const conf = { 47 | name: 'foo-secret', 48 | files: ['foo.bin'], 49 | literals: ['foo.literal=foobar'], 50 | }; 51 | 52 | expect.assertions(1); 53 | return gen(conf).then((v) => { 54 | expect(v).toEqual(new core.v1.Secret('foo-secret', { 55 | data: { 56 | 'foo.bin': foobarEncoded, 57 | 'foo.literal': foobarEncoded, 58 | } 59 | })); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/generate.test.js: -------------------------------------------------------------------------------- 1 | import { valuesForGenerate } from '../src/generate'; 2 | import { core, apps } from '../src/api'; 3 | 4 | test('valuesForGenerate', () => { 5 | const output = {}; 6 | const write = ({ file, value }) => { 7 | output[file] = value; 8 | }; 9 | 10 | // I'm mostly checking whether it gets the paths and so on correct. 11 | const resources = [ 12 | new core.v1.Namespace('foo', {}), 13 | new apps.v1.Deployment('bar', { 14 | metadata: { namespace: 'foo' }, 15 | }), 16 | new core.v1.Service('foosrv', { 17 | metadata: { namespace: 'foo' }, 18 | }), 19 | ]; 20 | 21 | expect.assertions(1); 22 | return valuesForGenerate(resources).then(files => { 23 | files.forEach(write); 24 | expect(output).toEqual(expect.objectContaining({ 25 | 'foo-namespace.yaml': expect.any(core.v1.Namespace), 26 | 'foo/bar-deployment.yaml': expect.any(apps.v1.Deployment), 27 | 'foo/foosrv-service.yaml': expect.any(core.v1.Service), 28 | })); 29 | }); 30 | }); 31 | 32 | test('valuesForGenerate (flatten)', () => { 33 | const output = {}; 34 | const write = ({ file, value }) => { 35 | output[file] = value; 36 | }; 37 | 38 | // I'm mostly checking whether it gets the paths and so on correct. 39 | const resources = [ 40 | new core.v1.Namespace('foo', {}), 41 | new apps.v1.Deployment('bar', { 42 | metadata: { namespace: 'foo' }, 43 | }), 44 | new core.v1.Service('foosrv', { 45 | metadata: { namespace: 'foo' }, 46 | }), 47 | ]; 48 | 49 | expect.assertions(1); 50 | return valuesForGenerate(resources, { namespaceDirs: false }).then(files => { 51 | files.forEach(write); 52 | expect(output).toEqual(expect.objectContaining({ 53 | 'foo-namespace.yaml': expect.any(core.v1.Namespace), 54 | 'bar-deployment.yaml': expect.any(apps.v1.Deployment), 55 | 'foosrv-service.yaml': expect.any(core.v1.Service), 56 | })); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /cmd/apigen/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016-2018, Pulumi Corporation. 2 | // Copyright 2018, The jk Authors. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package main 17 | 18 | import ( 19 | "encoding/json" 20 | "fmt" 21 | "io/ioutil" 22 | "log" 23 | "os" 24 | 25 | "github.com/jkcfg/kubernetes/pkg/gen" 26 | ) 27 | 28 | func main() { 29 | if len(os.Args) < 4 { 30 | log.Fatal("Usage: gen ") 31 | } 32 | 33 | swagger, err := ioutil.ReadFile(os.Args[1]) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | data := map[string]interface{}{} 39 | err = json.Unmarshal(swagger, &data) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | templateDir := os.Args[2] 45 | outdir := fmt.Sprintf("%s", os.Args[3]) 46 | 47 | writeNodeJSClient(data, outdir, templateDir) 48 | } 49 | 50 | func writeNodeJSClient(data map[string]interface{}, outdir, templateDir string) { 51 | shapes, api, err := gen.NodeJSClient(data, templateDir) 52 | if err != nil { 53 | panic(err) 54 | } 55 | 56 | err = os.MkdirAll(outdir, 0700) 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | typesDir := fmt.Sprintf("%s", outdir) 62 | err = os.MkdirAll(typesDir, 0770) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | err = ioutil.WriteFile(fmt.Sprintf("%s/shapes.ts", typesDir), []byte(shapes), 0660) 68 | if err != nil { 69 | panic(err) 70 | } 71 | 72 | err = ioutil.WriteFile(fmt.Sprintf("%s/api.ts", outdir), []byte(api), 0660) 73 | if err != nil { 74 | panic(err) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/resources.test.js: -------------------------------------------------------------------------------- 1 | import { iterateContainers } from '../src/resources'; 2 | import { apps, core, meta, batch } from '../src/api'; 3 | 4 | test('find all containers in a Deployment', () => { 5 | const dep = new apps.v1.Deployment('foo', { 6 | metadata: { 7 | namespace: 'foons', 8 | }, 9 | spec: { 10 | template: { 11 | spec: { 12 | initContainers: [ 13 | { name: 'container1' }, 14 | ], 15 | containers: [ 16 | { name: 'container2' }, 17 | ], 18 | otherContainers: [ 19 | { name: 'container3' } 20 | ], 21 | }, 22 | }, 23 | } 24 | }); 25 | const names = Array.from(iterateContainers(dep)).map(({ name }) => name); 26 | expect(names).toEqual(['container1', 'container2']); 27 | }); 28 | 29 | test('find all containers in a Job', () => { 30 | const dep = new batch.v1.Job('job', { 31 | metadata: { 32 | namespace: 'foons', 33 | }, 34 | spec: { 35 | template: { 36 | spec: { 37 | initContainers: [ 38 | { name: 'container1' }, 39 | ], 40 | containers: [ 41 | { name: 'container2' }, 42 | ], 43 | otherContainers: [ 44 | { name: 'container3' } 45 | ], 46 | }, 47 | }, 48 | } 49 | }); 50 | const names = Array.from(iterateContainers(dep)).map(({ name }) => name); 51 | expect(names).toEqual(['container1', 'container2']); 52 | }); 53 | 54 | test('find all containers in a CronJob', () => { 55 | const job = new batch.v1beta1.CronJob('foo', { 56 | metadata: { 57 | namespace: 'foons', 58 | }, 59 | spec: { 60 | jobTemplate: { 61 | spec: { 62 | template: { 63 | spec: { 64 | initContainers: [ 65 | { name: 'container1' }, 66 | ], 67 | containers: [ 68 | { name: 'container2' }, 69 | ], 70 | otherContainers: [ 71 | { name: 'container3' } 72 | ], 73 | }, 74 | }, 75 | }, 76 | }, 77 | } 78 | }); 79 | const names = Array.from(iterateContainers(job)).map(({ name }) => name); 80 | expect(names).toEqual(['container1', 'container2']); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/image-reference.test.js: -------------------------------------------------------------------------------- 1 | import { ImageReference } from '../src/image-reference'; 2 | 3 | // from https://github.com/docker/distribution/blob/master/reference/reference_test.go 4 | [ 5 | ['test_com', { path: 'test_com' }], 6 | ['test.com:tag', { path: 'test.com', tag: 'tag' }], 7 | ['test.com:5000', { path: 'test.com', tag: '5000' }], 8 | ['test.com/repo:tag', { domain: 'test.com', path: 'repo', tag: 'tag' }], 9 | ['test.com:5000/repo', { domain: 'test.com:5000', path: 'repo' } ], 10 | ['test.com:5000/repo:tag', { domain: 'test.com:5000', path: 'repo', tag: 'tag' } ], 11 | ['test:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', { 12 | domain: 'test:5000', 13 | path: 'repo', 14 | digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', 15 | }], 16 | ['test:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', { 17 | domain: 'test:5000', 18 | path: 'repo', 19 | tag: 'tag', 20 | digest: 'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', 21 | }], 22 | ['test:5000/repo', { domain: 'test:5000', path: 'repo' } ], 23 | [':justtag', { err: 'invalid' }], 24 | ['b.gcr.io/test.example.com/my-app:test.example.com', { 25 | domain: 'b.gcr.io', 26 | path: 'test.example.com/my-app', 27 | tag: 'test.example.com', 28 | }], 29 | ].forEach(([input, expected]) => { 30 | test(input, () => { 31 | const f = s => ImageReference.fromString(s); 32 | if (expected.err) { 33 | expect(() => f(input)).toThrow(expected.err); 34 | } else { 35 | expect(f(input)).toEqual(expected); 36 | } 37 | }); 38 | }); 39 | 40 | [ 41 | ['ubuntu'], 42 | ['ubuntu:18.04'], 43 | ['docker.io/library/ubuntu:18.04'], 44 | ['docker.io/library/ubuntu:18.04@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'], 45 | ].forEach(([input]) => { 46 | test(`toString: ${input}`, () => { 47 | const ref = ImageReference.fromString(input); 48 | expect(ref.toString()).toEqual(input); 49 | }); 50 | }); 51 | 52 | [ 53 | ['ubuntu', { output: 'ubuntu' }], 54 | ['ubuntu:18.04', { output: 'ubuntu' }], 55 | ['docker.io/library/ubuntu:18.04', { output: 'ubuntu' }], 56 | ].forEach(([input, expected]) => { 57 | test(`image: ${input}`, () => { 58 | const ref = ImageReference.fromString(input); 59 | expect(ref.image).toEqual(expected.output); 60 | }); 61 | }); -------------------------------------------------------------------------------- /src/overlay/generators.js: -------------------------------------------------------------------------------- 1 | import { dataFromFiles } from './data'; 2 | import { basename } from '../path'; 3 | import { core } from '../api'; 4 | import { encode as base64encode, ascii2bytes } from '../base64'; 5 | 6 | const generateConfigMap = readStr => async function generate(config) { 7 | const { 8 | name, 9 | files = [], 10 | literals = [], 11 | } = config; 12 | 13 | const data = {}; 14 | literals.forEach((s) => { 15 | const [k, v] = s.split('='); 16 | data[k] = v; 17 | }); 18 | const fileContents = dataFromFiles(readStr, files); 19 | return fileContents.then((d) => { 20 | d.forEach((v, k) => { 21 | data[basename(k)] = v; 22 | }); 23 | return new core.v1.ConfigMap(name, { data }); 24 | }); 25 | }; 26 | 27 | // In Kustomize, secrets are generally created from the result of 28 | // shelling out to some command (e.g., create an SSH key). Often these 29 | // won't be repeatable actions -- the usual mode of operation is such 30 | // that the value of the secret is cannot be used outside the 31 | // configuration. For example, a random password is constructed, and 32 | // supplied to both a server and the client that needs to connect to 33 | // it. 34 | // 35 | // Since we don't want to let the outside world in, with its icky 36 | // non-determinism, generateSecret here allows 37 | // 38 | // - strings (so long as they are ASCII; supporting UTF8 is possible, but would 39 | // need re-encoding) 40 | // - files, read as bytes 41 | // 42 | // This changes how you can use generated secrets: instead of creating 43 | // shared secrets internal to the configuration, as above, it is 44 | // mostly for things supplied from outside, e.g., via 45 | // parameters. Instead of the config being variations on `command` 46 | // (see 47 | // https://github.com/kubernetes-sigs/kustomize/blob/master/pkg/types/kustomization.go), 48 | // there are much the same fields as for ConfigMaps, the difference 49 | // being that the values end up being encoded base64. 50 | const generateSecret = readBytes => async function generate(config) { 51 | const { 52 | name, 53 | files = [], 54 | literals = [], 55 | } = config; 56 | 57 | const data = {}; 58 | literals.forEach((s) => { 59 | const [k, v] = s.split('='); 60 | data[k] = base64encode(ascii2bytes(v)); 61 | }); 62 | const fileContents = dataFromFiles(readBytes, files); 63 | return fileContents.then((d) => { 64 | d.forEach((v, k) => { 65 | data[basename(k)] = base64encode(v); 66 | }); 67 | return new core.v1.Secret(name, { data }); 68 | }); 69 | }; 70 | 71 | export { generateConfigMap, generateSecret }; 72 | -------------------------------------------------------------------------------- /src/overlay/compile.js: -------------------------------------------------------------------------------- 1 | // This module provides procedures for applying Kustomize-like 2 | // [overlays](https://github.com/kubernetes-sigs/kustomize/blob/master/docs/kustomization.yaml). 3 | // 4 | // In Kustomize, a configuration is given in a `kustomization.yaml` 5 | // file; here we'll interpret an object (which can of course be loaded 6 | // from a file). In a `kustomization.yaml` you refer to files from 7 | // which to load or generate resource manifests, and transformations 8 | // to apply to all or some resources. 9 | // 10 | // The mechanism for composing configurations is to name `bases` in 11 | // the `kustomization.yaml` file; these are evaluated and included in 12 | // the resources. 13 | // 14 | // The approach taken here is 15 | // 1. assemble all the transformations mentioned in various ways in the kustomize object; 16 | // 2. assemble all the resources, including from bases, in the kustomize object; 17 | // 3. run each resource through the transformations. 18 | // 19 | // Easy peasy! 20 | 21 | import { patchResource, commonMetadata } from '../transform'; 22 | import { generateConfigMap, generateSecret } from './generators'; 23 | 24 | const flatten = array => [].concat(...array); 25 | const pipeline = (...fns) => v => fns.reduce((acc, val) => val(acc), v); 26 | 27 | // The compile function here is parameterised with the means of 28 | // reading files: 29 | // 30 | // compile :: { read, Encoding } -> base path, object -> Promise [Resource] 31 | const compile = ({ read, Encoding }, opts = {}) => async function recurse(base, overlayObj) { 32 | const { file = 'kustomization.yaml' } = opts; 33 | const readObj = f => read(`${base}/${f}`, { encoding: Encoding.JSON }); 34 | const readStr = f => read(`${base}/${f}`, { encoding: Encoding.String }); 35 | const readBytes = f => read(`${base}/${f}`, { encoding: Encoding.Bytes }); 36 | 37 | const { 38 | // these are all fields interpreted by kustomize 39 | resources: resourceFiles = [], 40 | bases: baseFiles = [], 41 | patches: patchFiles = [], 42 | configMapGenerator = [], 43 | secretGenerator = [], 44 | 45 | // you can supply your own transformations as functions here 46 | transformations = [], 47 | // you can supply Promise [resource] values here, e.g., by calling chart(...) 48 | generatedResources = [], 49 | } = overlayObj; 50 | 51 | const patches = []; 52 | patchFiles.forEach((f) => { 53 | patches.push(readObj(f).then(patchResource)); 54 | }); 55 | 56 | // TODO: add the other kinds of transformation: imageTags, ..? 57 | 58 | const resources = generatedResources; 59 | 60 | baseFiles.forEach((f) => { 61 | const obj = readObj(`${f}/${file}`); 62 | resources.push(obj.then(o => recurse(`${base}/${f}`, o))); 63 | }); 64 | 65 | resources.push(Promise.all(resourceFiles.map(readObj))); 66 | resources.push(Promise.all(configMapGenerator.map(generateConfigMap(readStr)))); 67 | resources.push(Promise.all(secretGenerator.map(generateSecret(readBytes)))); 68 | 69 | const transform = pipeline(...transformations, 70 | ...await Promise.all(patches), 71 | commonMetadata(overlayObj)); 72 | return Promise.all(resources).then(flatten).then(rs => rs.map(transform)); 73 | }; 74 | 75 | export { compile }; 76 | -------------------------------------------------------------------------------- /src/transform/index.js: -------------------------------------------------------------------------------- 1 | import { merge } from '@jkcfg/std/merge'; 2 | import { iterateContainers } from '../resources'; 3 | 4 | // Interpret a series of transformations expressed either as object 5 | // patches (as in the argument to `patch` in this module), or 6 | // functions. Usually the first argument will be an object, 7 | // representing an initial value, but it can be a function (that will 8 | // be given an empty object as its argument). 9 | function mix(...transforms) { 10 | let r = {}; 11 | 12 | for (const transform of transforms) { 13 | switch (typeof transform) { 14 | case 'object': 15 | r = merge(r, transform); 16 | break; 17 | case 'function': 18 | r = transform(r); 19 | break; 20 | default: 21 | throw new TypeError('only objects and functions allowed as arguments'); 22 | } 23 | } 24 | 25 | return r; 26 | } 27 | 28 | 29 | // resourceMatch returns a predicate which gives true if the given 30 | // object represents the same resource as `template`, false otherwise. 31 | function resourceMatch(target) { 32 | // NaN is used for mandatory fields; if these are not present in the 33 | // template, nothing will match it (since NaN does not equal 34 | // anything, even itself). 35 | const { apiVersion = NaN, kind = NaN, metadata = {} } = target; 36 | const { name = NaN, namespace } = metadata; 37 | return (obj) => { 38 | const { apiVersion: v, kind: k, metadata: m } = obj; 39 | if (v !== apiVersion || k !== kind) return false; 40 | const { name: n, namespace: ns } = m; 41 | if (n !== name || ns !== namespace) return false; 42 | return true; 43 | }; 44 | } 45 | 46 | // patchResource returns a function that will patch the given object 47 | // if it refers to the same resource, and otherwise leave it 48 | // untouched. 49 | function patchResource(p) { 50 | const match = resourceMatch(p); 51 | return v => (match(v) ? merge(v, p) : v); 52 | } 53 | 54 | // commonMetadata returns a tranformation that will indiscriminately 55 | // add the given labels and annotations to every resource. 56 | function commonMetadata({ commonLabels = null, commonAnnotations = null, namespace = null }) { 57 | // This isn't quite as cute as it could be; naively, just assembling a patch 58 | // { metadata: { labels: commonLabels, annotations: commonAnnotations } 59 | // doesn't work, as it will assign null (or empty) values where they are not 60 | // present. 61 | const metaPatches = []; 62 | if (commonLabels !== null) { 63 | metaPatches.push({ metadata: { labels: commonLabels } }); 64 | } 65 | if (commonAnnotations !== null) { 66 | metaPatches.push({ metadata: { annotations: commonAnnotations } }); 67 | } 68 | if (namespace !== null) { 69 | metaPatches.push({ metadata: { namespace } }); 70 | } 71 | return r => mix(r, ...metaPatches); 72 | } 73 | 74 | // rewriteImageRefs applies the given rewrite function to each image 75 | // ref used in a resource. TBD(michael): should this use a zipper, so 76 | // as to not mutate? 77 | const rewriteImageRefs = rewrite => (resource) => { 78 | for (const container of iterateContainers(resource)) { 79 | container.image = rewrite(container.image); 80 | } 81 | return resource; 82 | }; 83 | 84 | export { patchResource, commonMetadata, rewriteImageRefs }; 85 | -------------------------------------------------------------------------------- /src/short/transform.js: -------------------------------------------------------------------------------- 1 | import { merge } from '@jkcfg/std/merge'; 2 | 3 | // "Field transformer" functions take a _value_ and return an object 4 | // with _one or more fields_. 5 | // 6 | // In `transform`, the `spec` argument defines how to transform an 7 | // object with a map of field name to field transformer; the result of 8 | // applying a transformer is merged into the result. 9 | 10 | // relocate makes a field transformer function that will relocate a 11 | // value to the path given. The trivial case is a single element, 12 | // which effectively renames a field. 13 | function relocate(path) { 14 | if (path === '') return v => v; 15 | 16 | const elems = path.split('.').reverse(); 17 | return (v) => { 18 | let obj = v; 19 | for (const p of elems) { 20 | obj = { [p]: obj }; 21 | } 22 | return obj; 23 | }; 24 | } 25 | 26 | // transformer returns a field transformer given: 27 | // 28 | // - a string, which relocates the field (possibly to a nested path); 29 | // - a function, which is used as-is; 30 | // - an object, which will be treated as the spec for transforming 31 | // the (assumed object) value to get a new value. 32 | function transformer(field) { 33 | switch (typeof field) { 34 | case 'string': return relocate(field); 35 | case 'function': return field; 36 | case 'object': 37 | return (Array.isArray(field)) ? thread(...field) : v => transform(field, v); 38 | default: return () => field; 39 | } 40 | } 41 | 42 | // mapper lifts a value transformer into an array transformer 43 | function mapper(fn) { 44 | const tx = transformer(fn); 45 | return vals => Array.prototype.map.call(vals, tx); 46 | } 47 | 48 | // thread takes a varying number of individual field transformers, and 49 | // returns a function that will apply each transformer to the result 50 | // of the previous. 51 | function thread(...transformers) { 52 | return initial => transformers.reduce((a, fn) => transformer(fn)(a), initial); 53 | } 54 | 55 | // drop takes a transformation spec and returns another spec, with 56 | // each field transformed as beofre, then relocated _under_ path. This 57 | // is useful for reusing a spec in a different context; e.g., the 58 | // podSpec in a deployment template. In that case the fields are all 59 | // expected at the top level of the short form, but relocated _en 60 | // masse_ to `spec.template.spec`. 61 | function drop(path, spec) { 62 | const reloc = relocate(path); 63 | const newSpec = {}; 64 | for (const [field, tx] of Object.entries(spec)) { 65 | newSpec[field] = thread(tx, reloc); 66 | } 67 | return newSpec; 68 | } 69 | 70 | // valueMap creates a field transformer that maps the possible values 71 | // to other values, then relocates the field. This is useful, for 72 | // example, when the format has shorthands or aliases for enum values 73 | // (like service.type='cluster-ip'). 74 | function valueMap(field, map) { 75 | return thread(v => map[v], field); 76 | } 77 | 78 | // transform generates a new value from `v0` based on the 79 | // specification given. Each field in `spec` contains a field 80 | // transformer, which is used to generate a new field or fields to 81 | // merge into the result. 82 | function transform(spec, v0) { 83 | let v1 = {}; 84 | for (const [field, value] of Object.entries(v0)) { 85 | const tx = spec[field]; 86 | if (tx !== undefined) { 87 | const fn = transformer(tx); 88 | v1 = merge(v1, fn(value)); 89 | } 90 | } 91 | return v1; 92 | } 93 | 94 | export { 95 | transform, relocate, valueMap, thread, mapper, drop, 96 | }; 97 | -------------------------------------------------------------------------------- /tests/short_transforms.test.js: -------------------------------------------------------------------------------- 1 | import { relocate, valueMap, transform, thread, mapper, drop } from '../src/short/transform'; 2 | 3 | test.each([ 4 | {path: '', result: 'value'}, 5 | {path: 'foo', result: { foo: 'value' } }, 6 | {path: 'foo.bar.baz', result: { foo: { bar: { baz: 'value' } } } }, 7 | ])('relocate', ({ path, result }) => { 8 | const rel = relocate(path); 9 | expect(rel('value')).toEqual(result); 10 | }); 11 | 12 | test.each([ 13 | {field: 'foo', map: {value: 'eulav'}, result: { 'foo': 'eulav'}}, 14 | ])('valueMap', ({ field, map, result }) => { 15 | const vmap = valueMap(field, map); 16 | expect(vmap('value')).toEqual(result); 17 | }); 18 | 19 | test('transform function', () => { 20 | const fn = _ => ({ always: 'replaced' }); 21 | const spec = { top: fn }; 22 | const value = { top: { value: 'discarded' } }; 23 | expect(transform(spec, value)).toEqual( 24 | { always: 'replaced' } 25 | ); 26 | }); 27 | 28 | test('transform relocate', () => { 29 | const spec = { top: 'to.the.bottom' }; 30 | const value = { top: 'value' }; 31 | expect(transform(spec, value)).toEqual( 32 | { to: { the: { bottom: 'value' } } } 33 | ); 34 | }); 35 | 36 | test('transform recurse', () => { 37 | const spec = { top: { sub: 'renamed' } }; 38 | const value = { top: { sub: 'value' } }; 39 | expect(transform(spec, value)).toEqual( 40 | { renamed: 'value' } 41 | ); 42 | }); 43 | 44 | test('transform thread', () => { 45 | const spec = { 46 | field: thread(valueMap('other', { 'value': 'eulav' }), 47 | 'nested.down.here') 48 | }; 49 | const value = { field: 'value' }; 50 | 51 | expect(transform(spec, value)).toEqual( 52 | { 53 | nested: { 54 | down: { 55 | here: { 56 | other: 'eulav', 57 | } 58 | } 59 | } 60 | } 61 | ); 62 | }); 63 | 64 | test ('transform drop', () => { 65 | const partPodSpec = { 66 | containers: [mapper({ name: 'name', image: 'image'}), 'spec.containers'], 67 | node: 'spec.nodeName', 68 | restart_policy: valueMap('spec.restartPolicy', { 69 | 'always': 'Always', 70 | 'on-failure': 'OnFailure', 71 | 'never': 'Never', 72 | }), 73 | }; 74 | const partDeploymentSpec = { 75 | name: 'metadata.name', 76 | ...drop('spec.template', partPodSpec), 77 | }; 78 | 79 | const shortVal = { 80 | name: 'hellodep', 81 | node: 'barnode', 82 | restart_policy: 'never', 83 | containers: [ 84 | { name: 'hello', image: 'helloworld' }, 85 | ], 86 | }; 87 | 88 | expect(transform(partDeploymentSpec, shortVal)).toEqual({ 89 | metadata: { name: shortVal.name }, 90 | spec: { 91 | template: { 92 | spec: { 93 | nodeName: shortVal.node, 94 | restartPolicy: 'Never', 95 | containers: [ 96 | { name: 'hello', image: 'helloworld' }, 97 | ], 98 | }, 99 | }, 100 | }, 101 | }); 102 | }); 103 | 104 | test('transform all', () => { 105 | const spec = { 106 | version: 'apiVersion', 107 | name: 'metadata.name', 108 | labels: 'metadata.labels', 109 | recreate: thread(v => (v) ? 'Recreate' : 'RollingUpdate', 'spec.strategy.type'), 110 | }; 111 | 112 | const value = { 113 | version: 'apps/v1', 114 | name: 'foo-dep', 115 | labels: { app: 'foo' }, 116 | recreate: false, 117 | }; 118 | 119 | expect(transform(spec, value)).toEqual( 120 | { 121 | apiVersion: 'apps/v1', 122 | spec: { 123 | strategy: { 124 | type: 'RollingUpdate', 125 | } 126 | }, 127 | metadata: { 128 | name: 'foo-dep', 129 | labels: { app: 'foo' }, 130 | }, 131 | } 132 | ); 133 | }); 134 | -------------------------------------------------------------------------------- /cmd/dedup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/Masterminds/semver" 14 | ) 15 | 16 | func main() { 17 | if len(os.Args) < 2 { 18 | log.Fatal("Usage: dedup src dest") 19 | } 20 | 21 | src, dest := os.Args[1], os.Args[2] 22 | log.Print("Copying schemas from src ", src, " to dest ", dest) 23 | 24 | // Go through all the directories src/*-local in semver order, and 25 | // construct like-named directories in dest but with duplicated 26 | // files as symlinks back to a prior version. 27 | 28 | dirs, err := filepath.Glob(filepath.Join(src, "v*-local")) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | versionDir := func(v *semver.Version) string { 34 | return "v" + v.String() + "-local" 35 | } 36 | 37 | vs := make([]*semver.Version, len(dirs)) 38 | for i, d := range dirs { 39 | r := filepath.Base(d) 40 | r = strings.TrimSuffix(r, "-local") 41 | v, err := semver.NewVersion(r) 42 | if err != nil { 43 | log.Fatalf("Error parsing version '%s': %s", r, err) 44 | } 45 | vs[i] = v 46 | } 47 | sort.Sort(semver.Collection(vs)) 48 | 49 | first, rest := vs[0], vs[1:] 50 | 51 | vdir := versionDir(first) 52 | files, err := filepath.Glob(filepath.Join(src, vdir, "*.json")) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | 57 | destdir := filepath.Join(dest, vdir) 58 | if err = os.MkdirAll(destdir, os.FileMode(0777)); err != nil { 59 | log.Fatal(err) 60 | } 61 | log.Printf("Transferring %d files from %s to %s", len(files), filepath.Join(src, vdir), destdir) 62 | for _, f := range files { 63 | b := filepath.Base(f) 64 | if err := copy(filepath.Join(destdir, b), f); err != nil { 65 | log.Fatal(err) 66 | } 67 | } 68 | 69 | for _, v := range rest { 70 | vdir := versionDir(v) 71 | destdir := filepath.Join(dest, vdir) 72 | if err := os.MkdirAll(destdir, os.FileMode(0777)); err != nil { 73 | log.Fatal(err) 74 | } 75 | 76 | files, err := filepath.Glob(filepath.Join(src, vdir, "*.json")) 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | log.Printf("Transferring %d files from %s to %s", len(files), filepath.Join(src, vdir), destdir) 81 | 82 | var copiedCount, linkedCount int 83 | for _, f := range files { 84 | b := filepath.Base(f) 85 | compare := filepath.Join(dest, versionDir(first), b) 86 | linked, err := copyOrSymlink(filepath.Join(destdir, b), f, compare) 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | if linked { 91 | linkedCount++ 92 | } else { 93 | copiedCount++ 94 | } 95 | } 96 | log.Printf("Linked %d, copied %d from %s to %s", linkedCount, copiedCount, first.String(), v.String()) 97 | 98 | first = v 99 | } 100 | } 101 | 102 | func copy(dest, src string) error { 103 | destfile, err := os.Create(dest) 104 | if err != nil { 105 | return err 106 | } 107 | defer destfile.Close() 108 | srcfile, err := os.Open(src) 109 | if err != nil { 110 | return err 111 | } 112 | defer srcfile.Close() 113 | _, err = io.Copy(destfile, srcfile) 114 | return err 115 | } 116 | 117 | // either copy src to dest, or if src is the same as compare, symlink 118 | // it. returns true if symlinked, false if copied 119 | func copyOrSymlink(dest, src, compare string) (bool, error) { 120 | srcstat, err := os.Stat(src) 121 | if err != nil { 122 | return false, err 123 | } 124 | comparestat, err := os.Stat(compare) 125 | if err != nil { 126 | return false, copy(dest, src) 127 | } 128 | 129 | // quick check: if the files aren't the same size, they can't be the same 130 | if srcstat.Size() != comparestat.Size() { 131 | return false, copy(dest, src) 132 | } 133 | 134 | // otherwise, check the contents 135 | srcbytes, err := ioutil.ReadFile(src) 136 | if err != nil { 137 | return false, err 138 | } 139 | comparebytes, err := ioutil.ReadFile(compare) 140 | if err != nil { 141 | return false, err 142 | } 143 | if bytes.Compare(srcbytes, comparebytes) == 0 { 144 | lstat, err := os.Lstat(compare) 145 | if err != nil { 146 | return true, err 147 | } 148 | var oldname string 149 | if lstat.Mode()&os.ModeSymlink != 0 { 150 | // since we do this each time, no need to chase it down 151 | oldname, err = os.Readlink(compare) 152 | if err != nil { 153 | return true, err 154 | } 155 | } else { 156 | // This is a bit of a cheat (we could pass the versions in 157 | // instead?). Anyway, the symlink must be relative, so 158 | // that it will survive being transplanted into an image 159 | oldname = filepath.Join("..", filepath.Base(filepath.Dir(compare)), filepath.Base(compare)) 160 | } 161 | return true, os.Symlink(oldname, dest) 162 | } 163 | return false, copy(dest, src) 164 | } 165 | -------------------------------------------------------------------------------- /tests/overlay.test.js: -------------------------------------------------------------------------------- 1 | import { compile } from '../src/overlay/compile'; 2 | import { fs, Encoding } from './mock'; 3 | import { core } from '../src/api'; 4 | import { merge, deepWithKey } from '@jkcfg/std/merge'; 5 | 6 | test('trivial overlay: no bases, resources, patches', () => { 7 | const { read } = fs({}, {}); 8 | const o = compile({ read, Encoding }); 9 | expect.assertions(1); 10 | return o('config', {}).then((v) => { 11 | expect(v).toEqual([]); 12 | }); 13 | }); 14 | 15 | 16 | const deployment = { 17 | apiVersion: 'apps/v1', 18 | kind: 'Deployment', 19 | metadata: { 20 | name: 'deploy1', 21 | namespace: 'test-ns', 22 | }, 23 | spec: { 24 | template: { 25 | spec: { 26 | containers: [ 27 | { name: 'test', image: 'tester:v1' }, 28 | ], 29 | }, 30 | }, 31 | }, 32 | }; 33 | 34 | const service = { 35 | apiVersion: 'v1', 36 | kind: 'Service', 37 | metadata: { 38 | name: 'service1', 39 | namespace: 'test-ns', 40 | }, 41 | }; 42 | 43 | 44 | test('load resources', () => { 45 | const kustomize = { 46 | resources: ['deployment.yaml', 'service.yaml'], 47 | }; 48 | const files = { 49 | './deployment.yaml': { json: deployment }, 50 | './service.yaml': { json: service }, 51 | }; 52 | const o = compile(fs({}, files)); 53 | expect.assertions(1); 54 | return o('.', kustomize).then((v) => { 55 | expect(v).toEqual([deployment, service]); 56 | }); 57 | }); 58 | 59 | test('load resources with generatedResources', () => { 60 | const files = { 61 | './deployment.yaml': { json: deployment }, 62 | }; 63 | const o = compile(fs({}, files)); 64 | 65 | const kustomize = { 66 | resources: ['deployment.yaml'], 67 | generatedResources: [Promise.resolve([service])], 68 | }; 69 | 70 | expect.assertions(1); 71 | return o('.', kustomize).then((v) => { 72 | expect(v).toEqual([service, deployment]); 73 | }); 74 | }); 75 | 76 | test('user-provided transformation', () => { 77 | const files = { 78 | './service.yaml': { json: service }, 79 | }; 80 | const o = compile(fs({}, files)); 81 | 82 | const insertSidecar = (v) => { 83 | if (v.kind === 'Deployment') { 84 | return merge(v, { 85 | spec: { 86 | template: { 87 | spec: { 88 | containers: [{ name: 'sidecar', image: 'side:v1' }], 89 | }, 90 | }, 91 | }, 92 | }, { 93 | spec: { 94 | template: { 95 | spec: { 96 | containers: deepWithKey('name'), 97 | } 98 | } 99 | } 100 | }); 101 | } 102 | return v; 103 | }; 104 | 105 | const kustom = { 106 | resources: ['service.yaml'], 107 | generatedResources: [Promise.resolve([deployment])], 108 | transformations: [insertSidecar], 109 | }; 110 | 111 | expect.assertions(3); 112 | return o('.', kustom).then((v) => { 113 | expect(v).toEqual([insertSidecar(deployment), service]); 114 | const [d, ] = v; 115 | // transformed deployment has extra container 116 | expect(d.spec.template.spec.containers.length).toEqual(2); 117 | // original deployment has no extra container 118 | expect(deployment.spec.template.spec.containers.length).toEqual(1); 119 | }); 120 | }); 121 | 122 | test('compose bases', () => { 123 | const subkustomize = { 124 | resources: ['deployment.yaml'], 125 | }; 126 | const kustomize = { 127 | bases: ['sub'], 128 | resources: ['service.yaml'], 129 | } 130 | const files = { 131 | './service.yaml': { json: service }, 132 | './sub/kustomization.yaml': { json: subkustomize }, 133 | './sub/deployment.yaml': { json: deployment }, 134 | }; 135 | 136 | const o = compile(fs({}, files)); 137 | expect.assertions(1); 138 | return o('.', kustomize).then((v) => { 139 | expect(v).toEqual([deployment, service]); 140 | }); 141 | }); 142 | 143 | test('patch resource', () => { 144 | const commonLabels = { app: 'foobar' }; 145 | const commonAnnotations = { awesome: 'true' }; 146 | 147 | const patch = { 148 | apiVersion: deployment.apiVersion, 149 | kind: deployment.kind, 150 | metadata: deployment.metadata, 151 | spec: { 152 | replicas: 10, 153 | }, 154 | }; 155 | 156 | const patchedDeployment = { 157 | ...deployment, 158 | metadata: { 159 | ...deployment.metadata, 160 | labels: commonLabels, 161 | annotations: commonAnnotations, 162 | }, 163 | spec: { 164 | ...deployment.spec, 165 | replicas: 10, 166 | }, 167 | }; 168 | const patchedService = { 169 | ...service, 170 | metadata: { 171 | ...service.metadata, 172 | labels: commonLabels, 173 | annotations: commonAnnotations, 174 | } 175 | }; 176 | 177 | const files = { 178 | './service.yaml': { json: service }, 179 | './deployment.yaml': { json: deployment }, 180 | './patch.yaml': { json: patch }, 181 | }; 182 | 183 | const kustomize = { 184 | commonLabels, 185 | commonAnnotations, 186 | resources: ['service.yaml', 'deployment.yaml'], 187 | patches: ['patch.yaml'], 188 | }; 189 | 190 | const o = compile(fs({}, files)); 191 | expect.assertions(1); 192 | return o('.', kustomize).then((v) => { 193 | expect(v).toEqual([patchedService, patchedDeployment]); 194 | }); 195 | }); 196 | 197 | test('generate resources', () => { 198 | const kustomize = { 199 | configMapGenerator: [ 200 | { 201 | name: 'foobar', 202 | literals: ['foo=bar'], 203 | files: ['bar'], 204 | }, 205 | ], 206 | secretGenerator: [ 207 | { 208 | name: 'ssshh', 209 | literals: ['foo=foobar'], 210 | } 211 | ], 212 | }; 213 | const files = { 214 | './bar': { string: 'foo' }, 215 | }; 216 | 217 | const configmap = new core.v1.ConfigMap('foobar', { 218 | data: { 219 | 'foo': 'bar', 220 | 'bar': 'foo', 221 | } 222 | }); 223 | const secret = new core.v1.Secret('ssshh', { 224 | data: { 225 | 'foo': 'Zm9vYmFy', 226 | } 227 | }); 228 | 229 | const o = compile(fs({}, files)); 230 | expect.assertions(1); 231 | return o('.', kustomize).then((v) => { 232 | expect(v).toEqual([configmap, secret]); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /src/image-reference.ts: -------------------------------------------------------------------------------- 1 | import { disposeEmitNodes } from "typescript"; 2 | 3 | // Grammar 4 | // 5 | // reference := name [ ":" tag ] [ "@" digest ] 6 | // name := [domain '/'] path-component ['/' path-component]* 7 | // domain := domain-component ['.' domain-component]* [':' port-number] 8 | // domain-component := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ 9 | // port-number := /[0-9]+/ 10 | // path-component := alpha-numeric [separator alpha-numeric]* 11 | // alpha-numeric := /[a-z0-9]+/ 12 | // separator := /[_.]|__|[-]*/ 13 | // 14 | // tag := /[\w][\w.-]{0,127}/ 15 | // 16 | // digest := digest-algorithm ":" digest-hex 17 | // digest-algorithm := digest-algorithm-component [ digest-algorithm-separator digest-algorithm-component ]* 18 | // digest-algorithm-separator := /[+.-_]/ 19 | // digest-algorithm-component := /[A-Za-z][A-Za-z0-9]*/ 20 | // digest-hex := /[0-9a-fA-F]{32,}/ ; At least 128 bit digest value 21 | // 22 | // identifier := /[a-f0-9]{64}/ 23 | // short-identifier := /[a-f0-9]{6,64}/ 24 | 25 | // Ref: https://github.com/docker/distribution/blob/master/reference/reference.go 26 | // Ref: https://github.com/docker/distribution/blob/master/reference/regexp.go 27 | // Ref: https://github.com/moby/moby/blob/master/image/spec/v1.2.md 28 | 29 | // NameTotalLengthMax is the maximum total number of characters in a repository name. 30 | const NameTotalLengthMax = 255 31 | 32 | function match(s: string|RegExp): RegExp { 33 | if (s instanceof RegExp) { 34 | return s; 35 | } 36 | return new RegExp(s); 37 | } 38 | 39 | function quoteMeta(s: string): string { 40 | return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); 41 | } 42 | 43 | // literal compiles s into a literal regular expression, escaping any regexp 44 | // reserved characters. 45 | function literal(s: string): RegExp { 46 | return match(quoteMeta(s)); 47 | } 48 | 49 | // expression defines a full expression, where each regular expression must 50 | // follow the previous. 51 | function expression(...res: RegExp[]): RegExp { 52 | let s = ''; 53 | for (const re of res) { 54 | s += re.source; 55 | } 56 | return match(s); 57 | } 58 | 59 | // optional wraps the expression in a non-capturing group and makes the 60 | // production optional. 61 | function optional(...res: RegExp[]): RegExp { 62 | return match(group(expression(...res)).source + '?'); 63 | 64 | } 65 | 66 | // repeated wraps the regexp in a non-capturing group to get one or more 67 | // matches. 68 | function repeated(...res: RegExp[]): RegExp { 69 | return match(group(expression(...res)).source + '+'); 70 | } 71 | 72 | // capture wraps the expression in a capturing group. 73 | function capture(...res: RegExp[]): RegExp { 74 | return match(`(` + expression(...res).source + `)`) 75 | } 76 | 77 | // anchored anchors the regular expression by adding start and end delimiters. 78 | function anchored(...res: RegExp[]): RegExp { 79 | return match(`^` + expression(...res).source + `$`) 80 | } 81 | 82 | // group wraps the regexp in a non-capturing group. 83 | function group(...res: RegExp[]): RegExp { 84 | return match(`(?:${expression(...res).source})`); 85 | } 86 | 87 | // alphaNumericRe defines the alpha numeric atom, typically a component of 88 | // names. This only allows lower case characters and digits. 89 | const alphaNumericRegexp = match(/[a-z0-9]+/); 90 | 91 | // separatorRegexp defines the separators allowed to be embedded in name components. 92 | // This allow one period, one or two underscore and multiple dashes. 93 | const separatorRegexp = match(/(?:[._]|__|[-]*)/); 94 | 95 | // nameComponentRegexp restricts registry path component names to start with at 96 | // least one letter or number, with following parts able to be separated by one 97 | // period, one or two underscore and multiple dashes. 98 | const nameComponentRegexp = expression( 99 | alphaNumericRegexp, 100 | optional(repeated(separatorRegexp, alphaNumericRegexp))); 101 | 102 | // domainComponentRegexp restricts the registry domain component of a 103 | // repository name to start with a component as defined by DomainRegexp 104 | // and followed by an optional port. 105 | const domainComponentRegexp = match(/(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/) 106 | 107 | // DomainRegexp defines the structure of potential domain components 108 | // that may be part of image names. This is purposely a subset of what is 109 | // allowed by DNS to ensure backwards compatibility with Docker image 110 | // names. 111 | const DomainRegexp = expression( 112 | domainComponentRegexp, 113 | optional(repeated(literal(`.`), domainComponentRegexp)), 114 | optional(literal(`:`), match(/[0-9]+/))) 115 | 116 | // TagRegexp matches valid tag names. From docker/docker:graph/tags.go. 117 | const TagRegexp = match(/[\w][\w.-]{0,127}/) 118 | 119 | // anchoredTagRegexp matches valid tag names, anchored at the start and 120 | // end of the matched string. 121 | const anchoredTagRegexp = anchored(TagRegexp) 122 | 123 | // DigestRegexp matches valid digests. 124 | const DigestRegexp = match(/[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][0-9a-fA-F]{32,}/) 125 | 126 | // anchoredDigestRegexp matches valid digests, anchored at the start and 127 | // end of the matched string. 128 | const anchoredDigestRegexp = anchored(DigestRegexp) 129 | 130 | // NameRegexp is the format for the name component of references. The 131 | // regexp has capturing groups for the domain and name part omitting 132 | // the separating forward slash from either. 133 | const NameRegexp = expression( 134 | optional(DomainRegexp, literal(`/`)), 135 | nameComponentRegexp, 136 | optional(repeated(literal(`/`), nameComponentRegexp))) 137 | 138 | // anchoredNameRegexp is used to parse a name value, capturing the 139 | // domain and trailing components. 140 | const anchoredNameRegexp = anchored( 141 | optional(capture(DomainRegexp), literal(`/`)), 142 | capture(nameComponentRegexp, 143 | optional(repeated(literal(`/`), nameComponentRegexp)))) 144 | 145 | // ReferenceRegexp is the full supported format of a reference. The regexp 146 | // is anchored and has capturing groups for name, tag, and digest 147 | // components. 148 | const ReferenceRegexp = anchored(capture(NameRegexp), 149 | optional(literal(":"), capture(TagRegexp)), 150 | optional(literal("@"), capture(DigestRegexp))) 151 | 152 | // IdentifierRegexp is the format for string identifier used as a 153 | // content addressable identifier using sha256. These identifiers 154 | // are like digests without the algorithm, since sha256 is used. 155 | const IdentifierRegexp = match(/([a-f0-9]{64})/) 156 | 157 | // ShortIdentifierRegexp is the format used to represent a prefix 158 | // of an identifier. A prefix may be used to match a sha256 identifier 159 | // within a list of trusted identifiers. 160 | const ShortIdentifierRegexp = match(/([a-f0-9]{6,64})/) 161 | 162 | // anchoredIdentifierRegexp is used to check or match an 163 | // identifier value, anchored at start and end of string. 164 | const anchoredIdentifierRegexp = anchored(IdentifierRegexp) 165 | 166 | // anchoredShortIdentifierRegexp is used to check if a value 167 | // is a possible identifier prefix, anchored at start and end 168 | // of string. 169 | const anchoredShortIdentifierRegexp = anchored(ShortIdentifierRegexp) 170 | 171 | interface VersionInfo { 172 | tag?: string; 173 | digest?: string; 174 | } 175 | 176 | class ImageReference { 177 | domain?: string; 178 | path: string; 179 | tag?: string; 180 | digest?: string; 181 | 182 | constructor(domain: string|undefined, path: string, version?: VersionInfo) { 183 | this.domain = domain; 184 | this.path = path; 185 | if (version) { 186 | this.tag = version.tag; 187 | this.digest = version.digest; 188 | } 189 | } 190 | 191 | get image(): string { 192 | const components = this.path.split('/'); 193 | return components[components.length - 1]; 194 | } 195 | 196 | toString(): string { 197 | let s = ''; 198 | 199 | if (this.domain) { 200 | s += this.domain + '/'; 201 | } 202 | s += this.path; 203 | if (this.tag) { 204 | s += ':' + this.tag; 205 | } 206 | if (this.digest) { 207 | s += '@' + this.digest; 208 | } 209 | 210 | return s; 211 | } 212 | 213 | static fromString(s: string): ImageReference { 214 | const matches = s.match(ReferenceRegexp); 215 | if (matches == null) { 216 | throw new Error(`invalid image reference`); 217 | } 218 | 219 | const name = matches[1], 220 | tag = matches[2], 221 | digest = matches[3]; 222 | 223 | if (name.length > NameTotalLengthMax) { 224 | throw new Error(`repository name must not be more than ${NameTotalLengthMax} characters`); 225 | } 226 | 227 | const nameMatches = name.match(anchoredNameRegexp); 228 | if (nameMatches == null) { 229 | throw new Error(`invalid image reference`); 230 | } 231 | const domain = nameMatches[1], 232 | path = nameMatches[2]; 233 | 234 | return new ImageReference(domain, path, { tag, digest }); 235 | } 236 | } 237 | 238 | export { 239 | ImageReference, 240 | }; 241 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2018,2019 Damien Lespiau 191 | Copyright 2018,2019 Michael Bridgen 192 | Copyright 2019 Weaveworks Ltd. 193 | 194 | Licensed under the Apache License, Version 2.0 (the "License"); 195 | you may not use this file except in compliance with the License. 196 | You may obtain a copy of the License at 197 | 198 | http://www.apache.org/licenses/LICENSE-2.0 199 | 200 | Unless required by applicable law or agreed to in writing, software 201 | distributed under the License is distributed on an "AS IS" BASIS, 202 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 203 | See the License for the specific language governing permissions and 204 | limitations under the License. 205 | -------------------------------------------------------------------------------- /src/short/kinds.js: -------------------------------------------------------------------------------- 1 | import { apps, core } from '../api'; 2 | import { transform, valueMap, mapper, drop } from './transform'; 3 | 4 | // Take a constructor (e.g., from the API) and return a transformer 5 | // that will construct the API resource given a API resource "shape". 6 | function makeResource(Ctor, spec) { 7 | return (v) => { 8 | const shape = transform(spec, v); 9 | let name = ''; 10 | if (shape && shape.metadata && shape.metadata.name) { 11 | ({ metadata: { name } } = shape); 12 | } 13 | return new Ctor(name, shape); 14 | }; 15 | } 16 | 17 | const topLevel = { 18 | version: 'apiVersion', 19 | // `kind` is not transformed here, rather used as the dispatch 20 | // mechanism, and supplied by the specific API resource constructor 21 | }; 22 | 23 | const objectMeta = { 24 | // ObjectMeta 25 | name: 'metadata.name', 26 | namespace: 'metadata.namespace', 27 | labels: 'metadata.labels', 28 | annotations: 'metadata.annotations', 29 | }; 30 | 31 | function volumeSpec(name, vol) { 32 | if (typeof vol === 'string') { 33 | return volumeSpec(name, { vol_type: vol }); 34 | } 35 | const { vol_type: volType } = vol; 36 | let spec; 37 | switch (volType) { 38 | case 'empty_dir': 39 | spec = { 40 | name, 41 | emptyDir: transform({ 42 | max_size: 'sizeLimit', 43 | medium: valueMap('medium', { 44 | memory: 'Memory', 45 | }), 46 | }, vol), 47 | }; 48 | break; 49 | default: 50 | throw new Error(`vol_type ${volType} not supported`); 51 | } 52 | 53 | return spec; 54 | } 55 | 56 | function volumes(volumeMap) { 57 | const vols = []; 58 | // In the original shorts, the value of `name` is used in a 59 | // volume(mount)'s `store` field to refer back to a particular 60 | // volume. This _could_ be checked at generation time, with a little 61 | // extra bookkeeping. 62 | for (const [name, spec] of Object.entries(volumeMap)) { 63 | vols.push(volumeSpec(name, spec)); 64 | } 65 | return { 66 | spec: { volumes: vols }, 67 | }; 68 | } 69 | 70 | function affinities() { 71 | throw new Error('affinity not supported yet'); 72 | } 73 | 74 | function hostAliases(specs) { 75 | const aliases = []; 76 | for (const spec of specs) { 77 | const [ip, ...hostnames] = spec.split(' '); 78 | aliases.push({ ip, hostnames }); 79 | } 80 | return { 81 | spec: { aliases }, 82 | }; 83 | } 84 | 85 | function hostMode(flags) { 86 | const spec = {}; 87 | for (const flag of flags) { 88 | switch (flag) { 89 | case 'net': 90 | spec.hostNetwork = true; 91 | break; 92 | case 'pid': 93 | spec.hostPID = true; 94 | break; 95 | case 'ipc': 96 | spec.hostIPC = true; 97 | break; 98 | default: 99 | throw new Error(`host mode flag ${flag} unexpected`); 100 | } 101 | } 102 | return { spec }; 103 | } 104 | 105 | function hostName(h) { 106 | const [hostname, ...subdomain] = h.split('.'); 107 | const spec = { hostname }; 108 | if (subdomain.length > 0) { 109 | spec.subdomain = subdomain.join('.'); 110 | } 111 | return { spec }; 112 | } 113 | 114 | function account(accountStr) { 115 | const [name, maybeAuto] = accountStr.split(':'); 116 | const spec = { serviceAccountName: name }; 117 | if (maybeAuto === 'auto') { 118 | spec.autoMountServiceAccountToken = true; 119 | } 120 | return { spec }; 121 | } 122 | 123 | function tolerations() { 124 | throw new Error('tolerations not implemented yet'); 125 | } 126 | 127 | function priority(p) { 128 | const { value } = p; 129 | const spec = { priority: value }; 130 | if (p.class !== undefined) { 131 | spec.priorityClassName = p.class; 132 | } 133 | return { spec }; 134 | } 135 | 136 | function envVars(envs) { 137 | const env = []; 138 | const envFrom = []; 139 | /* eslint-disable no-continue */ 140 | for (const e of envs) { 141 | if (typeof e === 'string') { 142 | const [name, value] = e.split('='); 143 | env.push({ name, value }); 144 | continue; 145 | } 146 | 147 | // There are two kinds of env entry using references: `env` which 148 | // refers to a specific field in a ConfigMap or Secret, and 149 | // `envFrom` which imports all values from ConfigMap or Secret, 150 | // possibly with a prefix. In shorts, which is meant is determined 151 | // by the presence of a third segment of the (':'-delimited) 152 | // `from` value. 153 | const { from, key, required } = e; 154 | const [kind, name, field] = from.split(':'); 155 | 156 | // If no field is given in `from`, it's an `envFrom` 157 | if (field === undefined) { 158 | const ref = { name }; 159 | if (required !== undefined) ref.optional = !required; 160 | const allFrom = { prefix: key }; 161 | switch (kind) { 162 | case 'config': 163 | allFrom.configMapRef = ref; 164 | break; 165 | case 'secret': 166 | allFrom.secretRef = ref; 167 | break; 168 | default: 169 | throw new Error(`kind for envVar of ${kind} not supported`); 170 | } 171 | envFrom.push(allFrom); 172 | continue; 173 | } 174 | 175 | // If a field is given, it's an `env`. In this case, the `key` 176 | // value is the name of the env var, and the field is the key in 177 | // the referenced resource. 178 | const ref = { key: field, name }; 179 | if (required !== undefined) ref.optional = !required; 180 | const valueFrom = {}; 181 | switch (kind) { 182 | case 'config': 183 | valueFrom.configMapKeyRef = ref; 184 | break; 185 | case 'secret': 186 | valueFrom.secretKeyRef = ref; 187 | break; 188 | default: 189 | throw new Error(`kind for envVar of ${kind} not supported`); 190 | } 191 | env.push({ name: key, valueFrom }); 192 | } 193 | return { env, envFrom }; 194 | } 195 | 196 | function resource(res) { 197 | return ({ min, max }) => { 198 | const resources = {}; 199 | if (min !== undefined) resources.requests = { [res]: min }; 200 | if (max !== undefined) resources.limits = { [res]: max }; 201 | return { resources }; 202 | }; 203 | } 204 | 205 | const action = { 206 | command: args => ({ exec: { command: args } }), 207 | net: () => { throw new Error('net actions not implemented yet'); }, 208 | }; 209 | 210 | const probe = { 211 | ...action, 212 | delay: 'initialDelaySeconds', 213 | timeout: 'timeoutSeconds', 214 | interval: 'periodSeconds', 215 | min_count_success: 'successThreshold', 216 | min_count_failure: 'failureThreshold', 217 | }; 218 | 219 | const portRe = /(?:(tcp|udp):\/\/)?(?:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):)?(?:(\d{1,5}):)?(\d{1,5})/; 220 | 221 | function ports(pp) { 222 | function parseContainerPort(p) { 223 | let spec = p; 224 | let name; 225 | if (typeof p === 'object') { 226 | for (name of Object.keys(p)) { 227 | spec = p[name]; 228 | break; 229 | } 230 | } else { 231 | spec = String(p); 232 | } 233 | const [/* scheme */, protocol, hostIP, hostPort, containerPort] = spec.match(portRe); 234 | const port = { 235 | name, 236 | protocol: protocol && protocol.toUpperCase(), 237 | hostIP, 238 | hostPort, 239 | containerPort, 240 | }; 241 | Object.keys(port).forEach(k => port[k] === undefined && delete port[k]); 242 | return port; 243 | } 244 | return { ports: pp.map(parseContainerPort) }; 245 | } 246 | 247 | /* eslint-disable quote-props */ 248 | const volumeMount = { 249 | mount: 'mountPath', 250 | store: 'name', 251 | propagation: valueMap('mountPropagation', { 252 | 'host-to-container': 'HostToContainer', 253 | 'bidirectional': 'Bidirectional', 254 | }), 255 | }; 256 | 257 | /* eslint-disable quote-props */ 258 | const containerSpec = { 259 | name: 'name', 260 | command: 'command', 261 | args: 'args', 262 | env: envVars, 263 | image: 'image', 264 | pull: valueMap('imagePullPolicy', { 265 | 'always': 'Always', 266 | 'never': 'Never', 267 | 'if-not-present': 'IfNotPresent', 268 | }), 269 | on_start: [action, 'lifecycle.postStart'], 270 | pre_stop: [action, 'lifecycle.preStop'], 271 | cpu: resource('cpu'), 272 | mem: resource('memory'), 273 | cap_add: 'securityContext.capabilities.add', 274 | cap_drop: 'securityContext.capabilities.drop', 275 | privileged: 'securityContext.privileged', 276 | allow_escalation: 'securityContext.allowPrivilegeEscalation', 277 | rw: [v => !v, 'securityContext.readOnlyRootFilesystem'], 278 | ro: 'securityContext.readOnlyRootFilesystem', 279 | force_non_root: 'securityContext.runAsNonRoot', 280 | uid: 'securityContext.runAsUser', 281 | selinux: 'securityContext.seLinuxOptions', 282 | liveness_probe: [probe, 'livenessProbe'], 283 | readiness_probe: [probe, 'readinessProbe'], 284 | expose: ports, 285 | stdin: 'stdin', 286 | stdin_once: 'stdinOnce', 287 | tty: 'tty', 288 | wd: 'workingDir', 289 | termination_message_path: 'terminationMessagePath', 290 | terminal_message_policy: valueMap('terminationMessagePolicy', { 291 | file: 'File', 292 | 'fallback-to-logs-on-error': 'FallbackToLogsOnError', 293 | }), 294 | volume: [mapper(volumeMount), 'volumeMounts'], 295 | }; 296 | 297 | /* eslint-disable object-shorthand */ 298 | const podTemplateSpec = { 299 | volumes: volumes, 300 | affinity: affinities, 301 | node: 'spec.nodeName', 302 | containers: [mapper(containerSpec), 'spec.containers'], 303 | init_containers: [mapper(containerSpec), 'spec.initContainers'], 304 | dns_policy: valueMap('spec.dnsPolicy', { 305 | 'cluster-first': 'ClusterFirst', 306 | 'cluster-first-with-host-net': 'ClusterFirstWithHostNet', 307 | 'default': 'Default', 308 | }), 309 | host_aliases: hostAliases, 310 | host_mode: hostMode, 311 | hostname: hostName, 312 | registry_secrets: [mapper(name => ({ name })), 'spec.imagePullSecrets'], 313 | restart_policy: valueMap('spec.restartPolicy', { 314 | 'always': 'Always', 315 | 'on-failure': 'OnFailure', 316 | 'never': 'Never', 317 | }), 318 | scheduler_name: 'spec.schedulerName', 319 | account: account, 320 | tolerations: tolerations, 321 | termination_grace_period: 'spec.terminationGracePeriodSeconds', 322 | active_deadline: 'spec.activeDeadlineSeconds', 323 | priority: priority, 324 | fs_gid: 'spec.securityContext.fsGroup', 325 | gids: 'spec.securityContext.supplementalGroups', 326 | }; 327 | 328 | const podSpec = { 329 | ...topLevel, 330 | ...objectMeta, 331 | ...podTemplateSpec, 332 | }; 333 | 334 | const deploymentSpec = { 335 | ...topLevel, 336 | ...objectMeta, 337 | // metadata (labels, annotations) are used in the pod template 338 | pod_meta: drop('spec.template', objectMeta), 339 | // these are particular to deployments 340 | replicas: 'spec.replicas', 341 | recreate: valueMap('spec.strategy.type', { 342 | true: 'Recreate', 343 | false: 'RollingUpdate', 344 | }), 345 | max_unavailable: 'spec.strategy.rollingUpdate.maxUnavailable', 346 | max_extra: 'spec.strategy.rollingUpdate.maxSurge', 347 | min_ready: 'spec.minReadySeconds', 348 | max_revs: 'spec.revisionHistoryLimit', 349 | progress_deadline: 'spec.progressDeadlineSeconds', 350 | paused: 'spec.paused', 351 | selector: 'spec.selector.matchLabels', 352 | // most of the pod spec fields appear as a pod template 353 | ...drop('spec.template', podTemplateSpec), 354 | }; 355 | 356 | function sessionAffinity(value) { 357 | switch (typeof value) { 358 | case 'boolean': 359 | return { sessionAffinity: 'ClientIP' }; 360 | case 'number': 361 | return { 362 | sessionAffinity: 'ClientIP', 363 | sessionAffinityConfig: { 364 | clientIP: { 365 | timeoutSeconds: value, 366 | }, 367 | }, 368 | }; 369 | default: 370 | throw new Error(`service stickiness of type ${typeof value} not supported`); 371 | } 372 | } 373 | 374 | const serviceSpec = { 375 | ...topLevel, 376 | ...objectMeta, 377 | cname: 'spec.externalName', 378 | type: valueMap('spec.type', { 379 | 'cluster-ip': 'ClusterIP', 380 | 'load-balancer': 'LoadBalancer', 381 | 'node-port': 'NodePort', 382 | }), 383 | selector: 'spec.selector', 384 | external_ips: 'externalIPs', 385 | // port, node_port, ports -> TODO 386 | cluster_ip: 'clusterIP', 387 | unready_endpoints: 'publishNotReadyAddresses', 388 | route_policy: valueMap('externalTrafficPolicy', { 389 | 'node-local': 'Node', 390 | 'cluster-wide': 'Cluster', 391 | }), 392 | stickiness: sessionAffinity, 393 | lb_ip: 'loadBalancerIP', 394 | lb_client_ips: 'loadBalancerSourceRanges', 395 | healthcheck_port: 'healthCheckNodePort', 396 | }; 397 | 398 | // TODO all the other ones. 399 | // TODO register new transforms (e.g., for custom resources). 400 | 401 | export default { 402 | namespace: makeResource(core.v1.Namespace, objectMeta), 403 | pod: makeResource(core.v1.Pod, podSpec), 404 | deployment: makeResource(apps.v1.Deployment, deploymentSpec), 405 | service: makeResource(core.v1.Service, serviceSpec), 406 | }; 407 | -------------------------------------------------------------------------------- /pkg/gen/typegen.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016-2018, Pulumi Corporation. 2 | // Copyright 2018, The jk Authors. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package gen 17 | 18 | import ( 19 | "fmt" 20 | "strings" 21 | 22 | linq "github.com/ahmetb/go-linq" 23 | "github.com/jinzhu/copier" 24 | wordwrap "github.com/mitchellh/go-wordwrap" 25 | "k8s.io/apimachinery/pkg/runtime/schema" 26 | "k8s.io/apimachinery/pkg/util/sets" 27 | ) 28 | 29 | const ( 30 | object = "object" 31 | stringT = "string" 32 | ) 33 | 34 | const ( 35 | apiRegistration = "apiregistration.k8s.io" 36 | ) 37 | 38 | // -------------------------------------------------------------------------- 39 | 40 | // A collection of data structures and utility functions to transform an OpenAPI spec for the 41 | // Kubernetes API into something that we can use for codegen'ing nodejs and Python clients. 42 | 43 | // -------------------------------------------------------------------------- 44 | 45 | // GroupConfig represents a Kubernetes API group (e.g., core, apps, extensions, etc.) 46 | type GroupConfig struct { 47 | group string 48 | versions []*VersionConfig 49 | } 50 | 51 | // Group returns the name of the group (e.g., `core` for core, etc.) 52 | func (gc *GroupConfig) Group() string { return gc.group } 53 | 54 | // Versions returns the set of version for some Kubernetes API group. For example, the `apps` group 55 | // has `v1beta1`, `v1beta2`, and `v1`. 56 | func (gc *GroupConfig) Versions() []*VersionConfig { return gc.versions } 57 | 58 | // VersionConfig represents a version of a Kubernetes API group (e.g., the `apps` group has 59 | // `v1beta1`, `v1beta2`, and `v1`.) 60 | type VersionConfig struct { 61 | version string 62 | kinds []*KindConfig 63 | 64 | gv *schema.GroupVersion // Used for sorting. 65 | apiVersion string 66 | rawAPIVersion string 67 | } 68 | 69 | // Version returns the name of the version (e.g., `apps/v1beta1` would return `v1beta1`). 70 | func (vc *VersionConfig) Version() string { return vc.version } 71 | 72 | // Kinds returns the set of kinds in some Kubernetes API group/version combination (e.g., 73 | // `apps/v1beta1` has the `Deployment` kind, etc.). 74 | func (vc *VersionConfig) Kinds() []*KindConfig { return vc.kinds } 75 | 76 | // KindsAndAliases will produce a list of kinds, including aliases (e.g., both `apiregistration` and 77 | // `apiregistration.k8s.io`). 78 | func (vc *VersionConfig) KindsAndAliases() []*KindConfig { 79 | kindsAndAliases := []*KindConfig{} 80 | for _, kind := range vc.kinds { 81 | kindsAndAliases = append(kindsAndAliases, kind) 82 | if strings.HasPrefix(kind.APIVersion(), apiRegistration) { 83 | alias := KindConfig{} 84 | err := copier.Copy(&alias, kind) 85 | if err != nil { 86 | panic(err) 87 | } 88 | rawAPIVersion := "apiregistration" + strings.TrimPrefix(kind.APIVersion(), apiRegistration) 89 | alias.rawAPIVersion = rawAPIVersion 90 | kindsAndAliases = append(kindsAndAliases, &alias) 91 | } 92 | } 93 | return kindsAndAliases 94 | } 95 | 96 | // ListKindsAndAliases will return all known `Kind`s that are lists, or aliases of lists. These 97 | // `Kind`s are not instantiated by the API server, and we must "flatten" them client-side to get an 98 | // accurate view of what resource operations we need to perform. 99 | func (vc *VersionConfig) ListKindsAndAliases() []*KindConfig { 100 | listKinds := []*KindConfig{} 101 | for _, kind := range vc.KindsAndAliases() { 102 | hasItems := false 103 | for _, prop := range kind.properties { 104 | if prop.name == "items" { 105 | hasItems = true 106 | break 107 | } 108 | } 109 | 110 | if strings.HasSuffix(kind.Kind(), "List") && hasItems { 111 | listKinds = append(listKinds, kind) 112 | } 113 | } 114 | 115 | return listKinds 116 | } 117 | 118 | // APIVersion returns the fully-qualified apiVersion (e.g., `storage.k8s.io/v1` for storage, etc.) 119 | func (vc *VersionConfig) APIVersion() string { return vc.apiVersion } 120 | 121 | // RawAPIVersion returns the "raw" apiVersion (e.g., `v1` rather than `core/v1`). 122 | func (vc *VersionConfig) RawAPIVersion() string { return vc.rawAPIVersion } 123 | 124 | // KindConfig represents a Kubernetes API kind (e.g., the `Deployment` type in 125 | // `apps/v1beta1/Deployment`). 126 | type KindConfig struct { 127 | kind string 128 | comment string 129 | properties []*Property 130 | requiredProperties []*Property 131 | optionalProperties []*Property 132 | hasMeta bool 133 | 134 | gvk *schema.GroupVersionKind // Used for sorting. 135 | apiVersion string 136 | rawAPIVersion string 137 | typeGuard string 138 | } 139 | 140 | // Kind returns the name of the Kubernetes API kind (e.g., `Deployment` for 141 | // `apps/v1beta1/Deployment`). 142 | func (kc *KindConfig) Kind() string { return kc.kind } 143 | 144 | // Comment returns the comments associated with some Kubernetes API kind. 145 | func (kc *KindConfig) Comment() string { return kc.comment } 146 | 147 | // Properties returns the list of properties that exist on some Kubernetes API kind (i.e., things 148 | // that we will want to `.` into, like `thing.apiVersion`, `thing.kind`, `thing.metadata`, etc.). 149 | func (kc *KindConfig) Properties() []*Property { return kc.properties } 150 | 151 | // RequiredProperties returns the list of properties that are required to exist on some Kubernetes 152 | // API kind (i.e., things that we will want to `.` into, like `thing.apiVersion`, `thing.kind`, 153 | // `thing.metadata`, etc.). 154 | func (kc *KindConfig) RequiredProperties() []*Property { return kc.requiredProperties } 155 | 156 | // OptionalProperties returns the list of properties that are optional on some Kubernetes API kind 157 | // (i.e., things that we will want to `.` into, like `thing.apiVersion`, `thing.kind`, 158 | // `thing.metadata`, etc.). 159 | func (kc *KindConfig) OptionalProperties() []*Property { return kc.optionalProperties } 160 | 161 | // HasMeta encodes if the object has a Meta field. 162 | func (kc *KindConfig) HasMeta() bool { return kc.hasMeta } 163 | 164 | // APIVersion returns the fully-qualified apiVersion (e.g., `storage.k8s.io/v1` for storage, etc.) 165 | func (kc *KindConfig) APIVersion() string { return kc.apiVersion } 166 | 167 | // RawAPIVersion returns the "raw" apiVersion (e.g., `v1` rather than `core/v1`). 168 | func (kc *KindConfig) RawAPIVersion() string { return kc.rawAPIVersion } 169 | 170 | // URNAPIVersion returns API version that can be used in a URN (e.g., using the backwards-compatible 171 | // alias `apiextensions` instead of `apiextensions.k8s.io`). 172 | func (kc *KindConfig) URNAPIVersion() string { 173 | if strings.HasPrefix(kc.apiVersion, apiRegistration) { 174 | return "apiregistration" + strings.TrimPrefix(kc.apiVersion, apiRegistration) 175 | } 176 | return kc.apiVersion 177 | } 178 | 179 | // TypeGuard returns the text of a TypeScript type guard for the given kind. 180 | func (kc *KindConfig) TypeGuard() string { return kc.typeGuard } 181 | 182 | // Property represents a property we want to expose on a Kubernetes API kind (i.e., things that we 183 | // will want to `.` into, like `thing.apiVersion`, `thing.kind`, `thing.metadata`, etc.). 184 | type Property struct { 185 | name string 186 | comment string 187 | propType string 188 | defaultValue string 189 | } 190 | 191 | // Name returns the name of the property. 192 | func (p *Property) Name() string { return p.name } 193 | 194 | // Comment returns the comments associated with some property. 195 | func (p *Property) Comment() string { return p.comment } 196 | 197 | // PropType returns the type of the property. 198 | func (p *Property) PropType() string { return p.propType } 199 | 200 | // DefaultValue returns the type of the property. 201 | func (p *Property) DefaultValue() string { return p.defaultValue } 202 | 203 | // -------------------------------------------------------------------------- 204 | 205 | // Utility functions. 206 | 207 | // -------------------------------------------------------------------------- 208 | 209 | func gvkFromRef(ref string) schema.GroupVersionKind { 210 | // TODO(hausdorff): Surely there is an official k8s function somewhere for doing this. 211 | split := strings.Split(ref, ".") 212 | return schema.GroupVersionKind{ 213 | Kind: split[len(split)-1], 214 | Version: split[len(split)-2], 215 | Group: split[len(split)-3], 216 | } 217 | } 218 | 219 | func stripPrefix(name string) string { 220 | const prefix = "#/definitions/" 221 | return strings.TrimPrefix(name, prefix) 222 | } 223 | 224 | func fmtComment(comment interface{}, prefix string, opts groupOpts) string { 225 | if comment == nil { 226 | return "" 227 | } 228 | 229 | var wrapParagraph func(line string) []string 230 | var renderComment func(lines []string) string 231 | wrapParagraph = func(paragraph string) []string { 232 | // Escape comment termination. 233 | escaped := strings.Replace(paragraph, "*/", "*‍/", -1) 234 | borderLen := len(prefix + " * ") 235 | wrapped := wordwrap.WrapString(escaped, 100-uint(borderLen)) 236 | return strings.Split(wrapped, "\n") 237 | } 238 | renderComment = func(lines []string) string { 239 | joined := strings.Join(lines, fmt.Sprintf("\n%s * ", prefix)) 240 | return fmt.Sprintf("/**\n%s * %s\n%s */", prefix, joined, prefix) 241 | } 242 | 243 | commentstr, _ := comment.(string) 244 | if len(commentstr) > 0 { 245 | split := strings.Split(commentstr, "\n") 246 | lines := []string{} 247 | for _, paragraph := range split { 248 | lines = append(lines, wrapParagraph(paragraph)...) 249 | } 250 | return renderComment(lines) 251 | } 252 | return "" 253 | } 254 | 255 | func makeTypescriptType(prop map[string]interface{}, opts groupOpts) string { 256 | if t, exists := prop["type"]; exists { 257 | tstr := t.(string) 258 | if tstr == "array" { 259 | return fmt.Sprintf("%s[]", makeTypescriptType(prop["items"].(map[string]interface{}), opts)) 260 | } else if tstr == "integer" { 261 | return "number" 262 | } else if tstr == object { 263 | // `additionalProperties` with a single member, `type`, denotes a map whose keys and 264 | // values both have type `type`. This type is never a `$ref`. 265 | if additionalProperties, exists := prop["additionalProperties"]; exists { 266 | mapType := additionalProperties.(map[string]interface{}) 267 | if ktype, exists := mapType["type"]; exists && len(mapType) == 1 { 268 | return fmt.Sprintf("{[key: %s]: %s}", ktype, ktype) 269 | } 270 | } 271 | } 272 | return tstr 273 | } 274 | 275 | ref := stripPrefix(prop["$ref"].(string)) 276 | const ( 277 | apiextensionsV1beta1 = "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1beta1" 278 | quantity = "io.k8s.apimachinery.pkg.api.resource.Quantity" 279 | intOrString = "io.k8s.apimachinery.pkg.util.intstr.IntOrString" 280 | v1Time = "io.k8s.apimachinery.pkg.apis.meta.v1.Time" 281 | v1MicroTime = "io.k8s.apimachinery.pkg.apis.meta.v1.MicroTime" 282 | v1beta1JSONSchemaPropsOrBool = apiextensionsV1beta1 + ".JSONSchemaPropsOrBool" 283 | v1beta1JSONSchemaPropsOrArray = apiextensionsV1beta1 + ".JSONSchemaPropsOrArray" 284 | v1beta1JSON = apiextensionsV1beta1 + ".JSON" 285 | v1beta1CRSubresourceStatus = apiextensionsV1beta1 + ".CustomResourceSubresourceStatus" 286 | ) 287 | 288 | switch ref { 289 | case quantity: 290 | return stringT 291 | case intOrString: 292 | return "number | string" 293 | case v1Time, v1MicroTime: 294 | // TODO: Automatically deserialized with `DateConstructor`. 295 | return stringT 296 | case v1beta1JSONSchemaPropsOrBool: 297 | return "apiextensions.v1beta1.JSONSchemaProps | boolean" 298 | case v1beta1JSONSchemaPropsOrArray: 299 | return "apiextensions.v1beta1.JSONSchemaProps | any[]" 300 | case v1beta1JSON, v1beta1CRSubresourceStatus: 301 | return "any" 302 | } 303 | 304 | gvk := gvkFromRef(ref) 305 | return fmt.Sprintf("%s.%s.%s", gvk.Group, gvk.Version, gvk.Kind) 306 | } 307 | 308 | func makeType(prop map[string]interface{}, opts groupOpts) string { 309 | return makeTypescriptType(prop, opts) 310 | } 311 | 312 | // -------------------------------------------------------------------------- 313 | 314 | // Core grouping logic. 315 | 316 | // -------------------------------------------------------------------------- 317 | 318 | type definition struct { 319 | gvk schema.GroupVersionKind 320 | name string 321 | data map[string]interface{} 322 | } 323 | 324 | type gentype int 325 | 326 | const ( 327 | api gentype = iota 328 | shapes 329 | ) 330 | 331 | const ( 332 | python = "python" 333 | typescript = "typescript" 334 | ) 335 | 336 | type groupOpts struct { 337 | generatorType gentype 338 | } 339 | 340 | func shapesOpts() groupOpts { return groupOpts{generatorType: shapes} } 341 | func apiOpts() groupOpts { return groupOpts{generatorType: api} } 342 | 343 | func createGroups(definitionsJSON map[string]interface{}, opts groupOpts) []*GroupConfig { 344 | // Map definition JSON object -> `definition` with metadata. 345 | definitions := []*definition{} 346 | linq.From(definitionsJSON). 347 | WhereT(func(kv linq.KeyValue) bool { 348 | defName := kv.Key.(string) 349 | // Skip these objects, special case. 350 | switch { 351 | // They're deprecated and empty. 352 | // 353 | // TODO(hausdorff): We can remove these now that we don't emit a `KindConfig` for an object 354 | // that has no properties. 355 | case strings.HasPrefix(defName, "io.k8s.kubernetes.pkg"): 356 | // Of no use. 357 | case !strings.HasPrefix(defName, "io.k8s.apimachinery.pkg.apis.meta") && 358 | strings.Contains(defName, "Status"): 359 | return false 360 | } 361 | return true 362 | }). 363 | SelectT(func(kv linq.KeyValue) *definition { 364 | defName := kv.Key.(string) 365 | return &definition{ 366 | gvk: gvkFromRef(defName), 367 | name: defName, 368 | data: definitionsJSON[defName].(map[string]interface{}), 369 | } 370 | }). 371 | ToSlice(&definitions) 372 | 373 | // 374 | // Assemble a `KindConfig` for each Kubernetes kind. 375 | // 376 | 377 | kinds := []*KindConfig{} 378 | linq.From(definitions). 379 | OrderByT(func(d *definition) string { return d.gvk.String() }). 380 | SelectManyT(func(d *definition) linq.Query { 381 | // Skip if there are no properties on the type. 382 | if _, exists := d.data["properties"]; !exists { 383 | return linq.From([]KindConfig{}) 384 | } 385 | 386 | // Make fully-qualified and "default" GroupVersion. The "default" GV is the `apiVersion` that 387 | // appears when writing Kubernetes YAML (e.g., `v1` instead of `core/v1`), while the 388 | // fully-qualified version is the "official" GV (e.g., `core/v1` instead of `v1` or 389 | // `admissionregistration.k8s.io/v1alpha1` instead of `admissionregistration/v1alpha1`). 390 | defaultGroupVersion := d.gvk.Group 391 | var fqGroupVersion string 392 | if gvks, gvkExists := 393 | d.data["x-kubernetes-group-version-kind"].([]interface{}); gvkExists && len(gvks) > 0 { 394 | gvk := gvks[0].(map[string]interface{}) 395 | group := gvk["group"].(string) 396 | version := gvk["version"].(string) 397 | if group == "" { 398 | defaultGroupVersion = version 399 | fqGroupVersion = fmt.Sprintf(`core/%s`, version) 400 | } else { 401 | defaultGroupVersion = fmt.Sprintf(`%s/%s`, group, version) 402 | fqGroupVersion = fmt.Sprintf(`%s/%s`, group, version) 403 | } 404 | } else { 405 | gv := d.gvk.GroupVersion().String() 406 | if strings.HasPrefix(gv, "apiextensions/") && strings.HasPrefix(d.gvk.Kind, "CustomResource") { 407 | // Special case. Kubernetes OpenAPI spec should have an `x-kubernetes-group-version-kind` 408 | // CustomResource, but it doesn't. Hence, we hard-code it. 409 | gv = fmt.Sprintf("apiextensions.k8s.io/%s", d.gvk.Version) 410 | } 411 | defaultGroupVersion = gv 412 | fqGroupVersion = gv 413 | } 414 | 415 | ps := linq.From(d.data["properties"]). 416 | WhereT(func(kv linq.KeyValue) bool { return kv.Key.(string) != "status" }). 417 | OrderByT(func(kv linq.KeyValue) string { return kv.Key.(string) }). 418 | SelectT(func(kv linq.KeyValue) *Property { 419 | propName := kv.Key.(string) 420 | prop := d.data["properties"].(map[string]interface{})[propName].(map[string]interface{}) 421 | 422 | // Create a default value for the field. 423 | defaultValue := fmt.Sprintf("desc.%s", propName) 424 | switch propName { 425 | case "apiVersion": 426 | defaultValue = fmt.Sprintf(`"%s"`, defaultGroupVersion) 427 | case "kind": 428 | defaultValue = fmt.Sprintf(`"%s"`, d.gvk.Kind) 429 | case "metadata": 430 | defaultValue = "Object.assign({}, desc && desc.metadata || {}, { name })" 431 | } 432 | 433 | prefix := " " 434 | t := makeType(prop, opts) 435 | 436 | return &Property{ 437 | comment: fmtComment(prop["description"], prefix, opts), 438 | propType: t, 439 | name: propName, 440 | defaultValue: defaultValue, 441 | } 442 | }) 443 | 444 | // All properties. 445 | properties := []*Property{} 446 | ps.ToSlice(&properties) 447 | 448 | // Required properties. 449 | reqdProps := sets.NewString() 450 | if reqd, hasReqd := d.data["required"]; hasReqd { 451 | for _, propName := range reqd.([]interface{}) { 452 | reqdProps.Insert(propName.(string)) 453 | } 454 | } 455 | 456 | requiredProperties := []*Property{} 457 | ps. 458 | WhereT(func(p *Property) bool { 459 | return reqdProps.Has(p.name) 460 | }). 461 | ToSlice(&requiredProperties) 462 | 463 | optionalProperties := []*Property{} 464 | ps. 465 | WhereT(func(p *Property) bool { 466 | return !reqdProps.Has(p.name) 467 | }). 468 | ToSlice(&optionalProperties) 469 | 470 | if len(properties) == 0 { 471 | return linq.From([]*KindConfig{}) 472 | } 473 | 474 | props := d.data["properties"].(map[string]interface{}) 475 | _, apiVersionExists := props["apiVersion"] 476 | _, hasMeta := props["metadata"] 477 | 478 | var typeGuard string 479 | if apiVersionExists { 480 | typeGuard = fmt.Sprintf(` 481 | export function is%s(o: any): o is %s { 482 | return o.apiVersion == "%s" && o.kind == "%s"; 483 | }`, d.gvk.Kind, d.gvk.Kind, defaultGroupVersion, d.gvk.Kind) 484 | } 485 | 486 | return linq.From([]*KindConfig{ 487 | { 488 | kind: d.gvk.Kind, 489 | // NOTE: This transformation assumes git users on Windows to set 490 | // the "check in with UNIX line endings" setting. 491 | comment: fmtComment(d.data["description"], " ", opts), 492 | properties: properties, 493 | requiredProperties: requiredProperties, 494 | optionalProperties: optionalProperties, 495 | hasMeta: hasMeta, 496 | gvk: &d.gvk, 497 | apiVersion: fqGroupVersion, 498 | rawAPIVersion: defaultGroupVersion, 499 | typeGuard: typeGuard, 500 | }, 501 | }) 502 | }). 503 | ToSlice(&kinds) 504 | 505 | // 506 | // Assemble a `VersionConfig` for each group of kinds. 507 | // 508 | 509 | versions := []*VersionConfig{} 510 | linq.From(kinds). 511 | GroupByT( 512 | func(e *KindConfig) schema.GroupVersion { return e.gvk.GroupVersion() }, 513 | func(e *KindConfig) *KindConfig { return e }). 514 | OrderByT(func(kinds linq.Group) string { 515 | return kinds.Key.(schema.GroupVersion).String() 516 | }). 517 | SelectManyT(func(kinds linq.Group) linq.Query { 518 | gv := kinds.Key.(schema.GroupVersion) 519 | kindsGroup := []*KindConfig{} 520 | linq.From(kinds.Group).ToSlice(&kindsGroup) 521 | if len(kindsGroup) == 0 { 522 | return linq.From([]*VersionConfig{}) 523 | } 524 | 525 | return linq.From([]*VersionConfig{ 526 | { 527 | version: gv.Version, 528 | kinds: kindsGroup, 529 | gv: &gv, 530 | apiVersion: kindsGroup[0].apiVersion, // NOTE: This is safe. 531 | rawAPIVersion: kindsGroup[0].rawAPIVersion, // NOTE: This is safe. 532 | }, 533 | }) 534 | }). 535 | ToSlice(&versions) 536 | 537 | // 538 | // Assemble a `GroupConfig` for each group of versions. 539 | // 540 | 541 | groups := []*GroupConfig{} 542 | linq.From(versions). 543 | GroupByT( 544 | func(e *VersionConfig) string { return e.gv.Group }, 545 | func(e *VersionConfig) *VersionConfig { return e }). 546 | OrderByT(func(versions linq.Group) string { return versions.Key.(string) }). 547 | SelectManyT(func(versions linq.Group) linq.Query { 548 | versionsGroup := []*VersionConfig{} 549 | linq.From(versions.Group).ToSlice(&versionsGroup) 550 | if len(versionsGroup) == 0 { 551 | return linq.From([]*GroupConfig{}) 552 | } 553 | 554 | return linq.From([]*GroupConfig{ 555 | { 556 | group: versions.Key.(string), 557 | versions: versionsGroup, 558 | }, 559 | }) 560 | }). 561 | WhereT(func(gc *GroupConfig) bool { 562 | return len(gc.Versions()) != 0 563 | }). 564 | ToSlice(&groups) 565 | 566 | return groups 567 | } 568 | --------------------------------------------------------------------------------