diff --git a/examples/k8s-apply/skeleton/cm.yaml b/examples/k8s-apply/skeleton/cm.yaml new file mode 100644 index 0000000..624feac --- /dev/null +++ b/examples/k8s-apply/skeleton/cm.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-demo +data: + # property-like keys; each key maps to a simple value + player_initial_lives: "3" + ui_properties_file_name: "user-interface.properties" + + # file-like keys + game.properties: | + enemy.types=aliens,monsters + player.maximum-lives=5 + user-interface.properties: | + color.good=purple + color.bad=yellow + allow.textmode=true diff --git a/examples/k8s-apply/template-manifest-object.yaml b/examples/k8s-apply/template-manifest-object.yaml new file mode 100644 index 0000000..f88963d --- /dev/null +++ b/examples/k8s-apply/template-manifest-object.yaml @@ -0,0 +1,41 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: deploy-resources-object + title: Deploy Resources using object + description: Deploy Resource to Kubernetes +spec: + owner: guest + type: service + # these are the steps which are rendered in the frontend with the form input + parameters: [] + steps: + - id: template + name: Generating component + action: fetch:template + input: + url: ./skeleton + - id: apply + name: apply-manifest + action: cnoe:kubernetes:apply + input: + namespaced: true + manifestObject: + apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + # property-like keys; each key maps to a simple value + player_initial_lives: "3" + ui_properties_file_name: "user-interface.properties" + + # file-like keys + game.properties: | + enemy.types=aliens,monsters + player.maximum-lives=5 + user-interface.properties: | + color.good=purple + color.bad=yellow + allow.textmode=true + clusterName: local diff --git a/examples/k8s-apply/template-manifest-string.yaml b/examples/k8s-apply/template-manifest-string.yaml new file mode 100644 index 0000000..312f557 --- /dev/null +++ b/examples/k8s-apply/template-manifest-string.yaml @@ -0,0 +1,41 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: deploy-resources-string + title: Deploy Resources using literal string + description: Deploy Resource to Kubernetes +spec: + owner: guest + type: service + # these are the steps which are rendered in the frontend with the form input + parameters: [] + steps: + - id: template + name: Generating component + action: fetch:template + input: + url: ./skeleton + - id: apply + name: apply-manifest + action: cnoe:kubernetes:apply + input: + namespaced: true + manifestString: | + apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + # property-like keys; each key maps to a simple value + player_initial_lives: "3" + ui_properties_file_name: "user-interface.properties" + + # file-like keys + game.properties: | + enemy.types=aliens,monsters + player.maximum-lives=5 + user-interface.properties: | + color.good=purple + color.bad=yellow + allow.textmode=true + clusterName: local diff --git a/examples/k8s-apply/template.yaml b/examples/k8s-apply/template.yaml new file mode 100644 index 0000000..3f097c3 --- /dev/null +++ b/examples/k8s-apply/template.yaml @@ -0,0 +1,30 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: deploy-resources + title: Deploy Resources + description: Deploy Resource to Kubernetes +spec: + owner: guest + type: service + # these are the steps which are rendered in the frontend with the form input + parameters: + - title: file name + properties: + path: + type: string + description: file name + default: cm.yaml + steps: + - id: template + name: Generating component + action: fetch:template + input: + url: ./skeleton + - id: apply + name: apply-manifest + action: cnoe:kubernetes:apply + input: + namespaced: true + manifestPath: cm.yaml + clusterName: local diff --git a/packages/backend/package.json b/packages/backend/package.json index 793c00c..48c0316 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -41,6 +41,7 @@ "@backstage/plugin-search-backend-node": "~1.2.13", "@backstage/plugin-techdocs-backend": "~1.9.2", "@backstage/types": "~1.1.1", + "@kubernetes/client-node": "~0.20.0", "@roadiehq/backstage-plugin-argo-cd-backend": "~2.14.0", "@roadiehq/scaffolder-backend-module-utils": "~1.13.1", "app": "link:../app", @@ -48,6 +49,7 @@ "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "fs-extra": "~11.2.0", "node-gyp": "^9.0.0", "pg": "^8.11.3", "winston": "^3.2.1" @@ -57,6 +59,7 @@ "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", + "@types/fs-extra": "^11.0.4", "@types/luxon": "^2.0.4" }, "files": [ diff --git a/packages/backend/src/plugins/k8s-apply.ts b/packages/backend/src/plugins/k8s-apply.ts new file mode 100644 index 0000000..733b6d3 --- /dev/null +++ b/packages/backend/src/plugins/k8s-apply.ts @@ -0,0 +1,186 @@ +import { createTemplateAction, executeShellCommand} from '@backstage/plugin-scaffolder-node'; +import { dumpYaml } from '@kubernetes/client-node'; +import yaml from 'js-yaml'; +import { Config } from '@backstage/config'; +import { resolveSafeChildPath } from '@backstage/backend-common'; +import fs from 'fs-extra'; + +export const createKubernetesApply = (config: Config) => { + return createTemplateAction<{ + manifestString?: string; + manifestObject?: any; + manifestPath?: string; + namespaced: boolean; + clusterName?: string; + }>({ + id: 'cnoe:kubernetes:apply', + schema: { + input: { + type: 'object', + required: ['namespaced'], + properties: { + manifestString: { + type: 'string', + title: 'Manifest', + description: + 'The manifest to apply in the cluster. Must be a string', + }, + manifestObject: { + type: 'object', + title: 'Manifest', + description: + 'The manifest to apply in the cluster. Must be an object', + }, + manifestPath: { + type: 'string', + title: 'Path to the manifest file', + description: 'The path to the manifest file.', + }, + namespaced: { + type: 'boolean', + title: 'Namespaced', + description: 'Whether the API is namespaced or not', + }, + clusterName: { + type: 'string', + title: 'Cluster Name', + description: 'The name of the cluster to apply this', + }, + }, + }, + output: { + type: 'object', + title: 'Returned object', + description: + 'The object returned by Kubernetes by performing this operation', + }, + }, + async handler(ctx) { + let obj: any; + let manifestPath = resolveSafeChildPath(ctx.workspacePath, 'to-be-applied.yaml'); + if (ctx.input.manifestString) { + obj = yaml.load(ctx.input.manifestString) + fs.writeFileSync(manifestPath, ctx.input.manifestString, { + encoding: 'utf8', + mode: '600', + }); + } else if (ctx.input.manifestObject) { + obj = ctx.input.manifestObject; + fs.writeFileSync(manifestPath, yaml.dump(ctx.input.manifestObject), { + encoding: 'utf8', + mode: '600', + }); + } else { + const filePath = resolveSafeChildPath( + ctx.workspacePath, + ctx.input.manifestPath!, + ); + const fileContent = fs.readFileSync(filePath, 'utf8'); + manifestPath = filePath + obj = yaml.load(fileContent); + } + + if (ctx.input.clusterName) { + // Supports SA token authentication only + const targetCluster = getClusterConfig(ctx.input.clusterName!, config); + const confFile = { + apiVersion: 'v1', + kind: 'Config', + 'current-context': ctx.input.clusterName, + contexts: [ + { + name: ctx.input.clusterName, + context: { + cluster: ctx.input.clusterName, + user: ctx.input.clusterName, + }, + }, + ], + clusters: [ + { + name: ctx.input.clusterName, + cluster: { + 'certificate-authority-data': targetCluster.getOptionalString('caData'), + 'certificate-authority': targetCluster.getOptionalString('caFile'), + server: targetCluster.getString('url'), + 'insecure-skip-tls-verify': !!targetCluster.getOptionalBoolean('skipTLSVerify'), + }, + }, + ], + users: [ + { + name: ctx.input.clusterName, + user: { + token: targetCluster.getString('serviceAccountToken'), + }, + }, + ], + }; + if (!confFile.clusters[0].cluster["insecure-skip-tls-verify"]) { + let caDataRaw = targetCluster.getOptionalString('caData') + if (caDataRaw?.startsWith('-----BEGIN CERTIFICATE-----')) { + caDataRaw = Buffer.from(targetCluster.getString('caData'), 'utf8').toString( + 'base64', + ); + } + confFile.clusters[0].cluster['certificate-authority-data'] = caDataRaw + } + const confString = dumpYaml(confFile); + const confFilePath = resolveSafeChildPath(ctx.workspacePath, 'config'); + fs.writeFileSync(confFilePath, confString, { + encoding: 'utf8', + mode: '600', + }); + await executeShellCommand({ + command: 'cat', + args: [confFilePath], + logStream: ctx.logStream, + }); + await executeShellCommand({ + command: 'cat', + args: [manifestPath], + logStream: ctx.logStream, + }); + if (obj.metadata.generateName !== undefined) { + await executeShellCommand({ + command: 'kubectl', + args: ['--kubeconfig', confFilePath, 'create', '-f', manifestPath], + logStream: ctx.logStream, + }); + return; + } + await executeShellCommand({ + command: 'kubectl', + args: ['--kubeconfig', confFilePath, 'apply', '-f', manifestPath], + logStream: ctx.logStream, + }); + return; + } + throw new Error("please specify a valid cluster name") + }, + }); +}; + +// Finds the first cluster that matches the given name. +function getClusterConfig(name: string, config: Config): Config { + const clusterConfigs = config + .getConfigArray('kubernetes.clusterLocatorMethods') + .filter((val: Config) => { + return val.getString('type') === 'config'; + }); + + const clusters = new Array(); + clusterConfigs.filter((conf: Config) => { + const cluster = conf.getConfigArray('clusters').find((val: Config) => { + return val.getString('name') === name; + }); + if (cluster) { + clusters.push(cluster); + } + }); + + if (clusters.length === 0) { + throw new Error(`Cluster with name ${name} not found`); + } + return clusters[0]; +} diff --git a/packages/backend/src/plugins/sanitize.ts b/packages/backend/src/plugins/sanitize.ts new file mode 100644 index 0000000..03a87b1 --- /dev/null +++ b/packages/backend/src/plugins/sanitize.ts @@ -0,0 +1,68 @@ +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import yaml from 'js-yaml'; + +// Add type annotations to fix TS2742 +type SanitizeResourceInput = { + document: string; +}; + +type SanitizeResourceOutput = { + sanitized: string; +}; + +export const createSanitizeResource = () => { + return createTemplateAction({ + id: 'cnoe:utils:sanitize', + schema: { + input: { + type: 'object', + required: ['document'], + properties: { + document: { + type: 'string', + title: 'Document', + description: 'The document to be sanitized', + }, + }, + }, + output: { + type: 'object', + properties: { + sanitized: { + type: 'string', + description: 'The sanitized yaml string' + } + } + } + }, + async handler(ctx) { + const obj = yaml.load(ctx.input.document); + ctx.output('sanitized', yaml.dump(removeEmptyObjects(obj))); + }, + }); +}; + +// Remove empty elements from an object +function removeEmptyObjects(obj: any): any { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + const newObj: any = Array.isArray(obj) ? [] : {}; + + for (const key in obj) { + const value = obj[key]; + const newValue = removeEmptyObjects(value); + if ( + !( + newValue === null || + newValue === undefined || + (typeof newValue === 'object' && Object.keys(newValue).length === 0) + ) + ) { + newObj[key] = newValue; + } + } + + return newObj; +} diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index a577370..87738e5 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -21,6 +21,9 @@ import { createJsonJSONataTransformAction, createReplaceInFileAction } from '@roadiehq/scaffolder-backend-module-utils'; +import {createKubernetesApply} from "./k8s-apply"; +import {createSanitizeResource} from "./sanitize"; +import {createVerifyDependency} from "./verify"; export default async function createPlugin( env: PluginEnvironment, @@ -48,7 +51,10 @@ export default async function createPlugin( const cnoeActions = [ createPublishGiteaAction(options), - createArgoCDApp(argocdOptions) + createArgoCDApp(argocdOptions), + createKubernetesApply(env.config), + createSanitizeResource(), + createVerifyDependency() ] const roadieUtilActions = [ diff --git a/packages/backend/src/plugins/verify.ts b/packages/backend/src/plugins/verify.ts new file mode 100644 index 0000000..32e9737 --- /dev/null +++ b/packages/backend/src/plugins/verify.ts @@ -0,0 +1,67 @@ +import { executeShellCommand } from '@backstage/plugin-scaffolder-node'; +import { createTemplateAction }from '@backstage/plugin-scaffolder-node'; +import {Writable} from 'stream'; + +class ConsoleLogStream extends Writable { + data: string; + + constructor(options: any) { + super(options); + this.data = ''; + } + + _write(chunk: any, _: any, callback: any) { + this.data += chunk.toString(); // Convert the chunk to a string and append it to this.data + console.log(this.data) + callback(); + } +} + +export const createVerifyDependency = () => { + return createTemplateAction<{ + verifiers: string[]; + }>({ + id: 'cnoe:verify:dependency', + schema: { + input: { + type: 'object', + required: ['verifiers'], + properties: { + verifiers: { + type: 'array', + items: { + type: 'string', + }, + title: 'verifiers', + description: 'The list of verifiers', + }, + }, + }, + }, + async handler(ctx) { + const verifiers = ctx.input.verifiers + + if (verifiers === null || verifiers.length === 0) { + ctx.logger.error('no verifier was supplied for the object') + return + } + + const baseCommand = 'cnoe' + const baseArguments = ['k8s', 'verify'] + + verifiers.forEach((verifier: string) => baseArguments.push("--config", verifier)) + + const logStream = new ConsoleLogStream({}); + await executeShellCommand({ + command: baseCommand, + args: baseArguments, + logStream: logStream, + }).then(() => + ctx.logger.info("verification succeeded") + ).catch((error) => { + ctx.logger.error(error) + throw new Error(logStream.data) + }); + }, + }); +};