diff --git a/README.md b/README.md index 404598750..adeb5084f 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ Minikube tools to be installed and available on your PATH. * `Helm: Insert Dependency` - Insert a dependency YAML fragment * `Helm: Dependency Update` - Update a chart's dependencies * `Helm: Package` - Package a chart directory into a chart archive + * `Helm: Convert to Template` - Create a template based on an existing resource or manifest + * `Helm: Convert to Template Parameter` - Convert a fixed value in a template to a parameter in the `values.yaml` file * Code lenses for: * requirements.yaml (Add and update dependencies) * Right-click on a chart .tgz file, and choose inspect chart to preview all configurable chart values. diff --git a/package-lock.json b/package-lock.json index 65c59e04c..6ec537571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6089,6 +6089,11 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, + "yaml-ast-parser": { + "version": "0.0.40", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.40.tgz", + "integrity": "sha1-CFNtTnPTIrHJziB6uN1w4E0grm4=" + }, "yamljs": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.2.10.tgz", diff --git a/package.json b/package.json index 8ad080526..8f6f1174a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "onCommand:extension.helmFetch", "onCommand:extension.helmInstall", "onCommand:extension.helmDependencies", + "onCommand:extension.helmConvertToTemplate", + "onCommand:extension.helmParameterise", "onCommand:extension.draftVersion", "onCommand:extension.draftCreate", "onCommand:extension.draftUp", @@ -176,6 +178,11 @@ "when": "", "command": "extension.helmInspectValues", "group": "2_helm@98" + }, + { + "when": "", + "command": "extension.helmConvertToTemplate", + "group": "2_helm@98" } ], "view/title": [ @@ -324,6 +331,11 @@ "group": "3", "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource" }, + { + "command": "extension.helmConvertToTemplate", + "group": "2", + "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource" + }, { "command": "extension.vsKubernetesLoad", "group": "0", @@ -369,6 +381,11 @@ "group": "3", "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource.pod" }, + { + "command": "extension.helmConvertToTemplate", + "group": "2", + "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource.pod" + }, { "command": "extension.vsKubernetesShowLogs", "group": "3", @@ -404,6 +421,11 @@ "group": "3", "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource.configmap" }, + { + "command": "extension.helmConvertToTemplate", + "group": "2", + "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource.configmap" + }, { "command": "extension.vsKubernetesAddFile", "group": "3", @@ -424,6 +446,11 @@ "group": "2@1", "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource.secret" }, + { + "command": "extension.helmConvertToTemplate", + "group": "2", + "when": "view == extension.vsKubernetesExplorer && viewItem == vsKubernetes.resource.secret" + }, { "command": "extension.vsKubernetesDelete", "group": "2@2", @@ -827,6 +854,18 @@ "description": "Create a new Helm Chart", "category": "Helm" }, + { + "command": "extension.helmConvertToTemplate", + "title": "Convert to Template", + "description": "Convert this manifest to a Helm template", + "category": "Helm" + }, + { + "command": "extension.helmParameterise", + "title": "Convert to Template Parameter", + "description": "Convert this value to a Helm template parameter", + "category": "Helm" + }, { "command": "extension.helmGet", "title": "Get Release", @@ -1035,6 +1074,7 @@ "vscode-debugprotocol": "1.27.0", "vscode-extension-telemetry": "^0.0.6", "vscode-uri": "^1.0.1", + "yaml-ast-parser": "^0.0.40", "yamljs": "0.2.10" }, "devDependencies": { diff --git a/src/explorer.ts b/src/explorer.ts index 2217aae2a..28fa8b52c 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -7,6 +7,7 @@ import { Host } from './host'; import * as kuberesources from './kuberesources'; import { failed } from './errorable'; import * as helmexec from './helm.exec'; +import { K8S_RESOURCE_SCHEME, KUBECTL_RESOURCE_AUTHORITY, kubefsUri } from './kuberesources.virtualfs'; const KUBERNETES_CLUSTER = "vsKubernetes.cluster"; const MINIKUBE_CLUSTER = "vsKubernetes.minikubeCluster"; @@ -49,9 +50,14 @@ export interface KubernetesObject { export interface ResourceNode { readonly id: string; readonly resourceId: string; + uri(outputFormat: string): vscode.Uri; namespace: string | null; } +export function isKubernetesExplorerResourceNode(obj: any): obj is ResourceNode { + return obj && obj.id && obj.resourceId; +} + export class KubernetesExplorer implements vscode.TreeDataProvider { private onDidChangeTreeDataEmitter: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this.onDidChangeTreeDataEmitter.event; @@ -238,6 +244,10 @@ class KubernetesResource implements KubernetesObject, ResourceNode { return (this.metadata && this.metadata.namespace) ? this.metadata.namespace : null; } + uri(outputFormat: string): vscode.Uri { + return kubefsUri(this.namespace, this.resourceId, outputFormat); + } + getChildren(kubectl: Kubectl, host: Host): vscode.ProviderResult { return []; } diff --git a/src/extension.ts b/src/extension.ts index 106561858..69b68f49b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { create as minikubeCreate, CheckPresentMode as MinikubeCheckPresentMode import * as logger from './logger'; import * as helm from './helm'; import * as helmexec from './helm.exec'; +import * as helmauthoring from './helm.authoring'; import { HelmRequirementsCodeLensProvider } from './helm.requirementsCodeLens'; import { HelmTemplateHoverProvider } from './helm.hoverProvider'; import { HelmTemplatePreviewDocumentProvider, HelmInspectDocumentProvider, HelmDependencyDocumentProvider } from './helm.documentProvider'; @@ -65,9 +66,10 @@ import { KubernetesCompletionProvider } from "./yaml-support/yaml-snippet"; import { showWorkspaceFolderPick } from './hostutils'; import { DraftConfigurationProvider } from './draft/draftConfigurationProvider'; import { installHelm, installDraft, installKubectl, installMinikube } from './components/installer/installer'; -import { KubernetesResourceVirtualFileSystemProvider, K8S_RESOURCE_SCHEME, KUBECTL_RESOURCE_AUTHORITY } from './kuberesources.virtualfs'; +import { KubernetesResourceVirtualFileSystemProvider, K8S_RESOURCE_SCHEME, KUBECTL_RESOURCE_AUTHORITY, kubefsUri } from './kuberesources.virtualfs'; import { Container, isKubernetesResource, KubernetesCollection, Pod, KubernetesResource } from './kuberesources.objectmodel'; import { setActiveKubeconfig, getKnownKubeconfigs, addKnownKubeconfig } from './components/config/config'; +import { HelmDocumentSymbolProvider } from './helm.symbolProvider'; let explainActive = false; let swaggerSpecPromise = null; @@ -119,6 +121,7 @@ export async function activate(context): Promise { const previewProvider = new HelmTemplatePreviewDocumentProvider(); const inspectProvider = new HelmInspectDocumentProvider(); const dependenciesProvider = new HelmDependencyDocumentProvider(); + const helmSymbolProvider = new HelmDocumentSymbolProvider(); const completionProvider = new HelmTemplateCompletionProvider(); const completionFilter = [ "helm", @@ -188,6 +191,8 @@ export async function activate(context): Promise { registerCommand('extension.helmFetch', helmexec.helmFetch), registerCommand('extension.helmInstall', (o) => helmexec.helmInstall(kubectl, o)), registerCommand('extension.helmDependencies', helmexec.helmDependencies), + registerCommand('extension.helmConvertToTemplate', helmConvertToTemplate), + registerCommand('extension.helmParameterise', helmParameterise), // Commands - Draft registerCommand('extension.draftVersion', execDraftVersion), @@ -209,6 +214,9 @@ export async function activate(context): Promise { vscode.languages.registerCompletionItemProvider(completionFilter, completionProvider), vscode.languages.registerCompletionItemProvider('yaml', new KubernetesCompletionProvider()), + // Symbol providers + vscode.languages.registerDocumentSymbolProvider({ language: 'helm' }, helmSymbolProvider), + // Hover providers vscode.languages.registerHoverProvider( { language: 'json', scheme: 'file' }, @@ -307,6 +315,7 @@ export async function activate(context): Promise { context.subscriptions.push(element); }, this); await registerYamlSchemaSupport(); + vscode.workspace.registerTextDocumentContentProvider(configmaps.uriScheme, configMapProvider); return { apiVersion: '0.1', @@ -662,11 +671,8 @@ function loadKubernetes(explorerNode?: explorer.ResourceNode) { function loadKubernetesCore(namespace: string | null, value: string) { const outputFormat = vscode.workspace.getConfiguration('vs-kubernetes')['vs-kubernetes.outputFormat']; - const docname = `${value.replace('/', '-')}.${outputFormat}`; - const nonce = new Date().getTime(); - const nsquery = namespace ? `ns=${namespace}&` : ''; - const uri = `${K8S_RESOURCE_SCHEME}://${KUBECTL_RESOURCE_AUTHORITY}/${docname}?${nsquery}value=${value}&_=${nonce}`; - vscode.workspace.openTextDocument(vscode.Uri.parse(uri)).then((doc) => { + const uri = kubefsUri(namespace, value, outputFormat); + vscode.workspace.openTextDocument(uri).then((doc) => { if (doc) { vscode.window.showTextDocument(doc); } @@ -1865,4 +1871,40 @@ async function execDraftUp() { function editorIsActive(): boolean { // force type coercion return (vscode.window.activeTextEditor) ? true : false; -} \ No newline at end of file +} + +async function helmConvertToTemplate(arg?: any) { + const workspace = await showWorkspaceFolderPick(); + if (!workspace) { + return; + } + helmauthoring.convertToTemplate(fs, host, workspace.uri.fsPath, arg); +} + +async function helmParameterise() { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return; + } + + const document = activeEditor.document; + if (!document) { + return; + } + + const selection = activeEditor.selection; + if (!selection) { + return; + } + + const convertResult = await helmauthoring.convertToParameter(fs, host, document, selection); + + if (succeeded(convertResult)) { + const editor = await vscode.window.showTextDocument(convertResult.result.document); + const edit = convertResult.result.edit; + editor.revealRange(edit.range); + editor.selection = new vscode.Selection(edit.range.start, edit.range.end); // TODO: this isn't quite right because it gives us the insert-at selection not the resultant edit + } else { + vscode.window.showErrorMessage(convertResult.error[0]); + } +} diff --git a/src/fs.ts b/src/fs.ts index 3844c8a72..9fd81f597 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -11,6 +11,7 @@ export interface FS { unlinkAsync(path: string): Promise; existsAsync(path: string): Promise; openAsync(path: string, flags: string): Promise; + statSync(path: string): sysfs.Stats; } export const fs: FS = { @@ -54,5 +55,7 @@ export const fs: FS = { resolve(); }); }); - } + }, + + statSync: (path) => sysfs.statSync(path) }; diff --git a/src/helm.authoring.ts b/src/helm.authoring.ts new file mode 100644 index 000000000..bb7a5458a --- /dev/null +++ b/src/helm.authoring.ts @@ -0,0 +1,357 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import * as _ from 'lodash'; +import { FS } from './fs'; +import { Host } from './host'; +import { ResourceNode, isKubernetesExplorerResourceNode } from './explorer'; +import { helmCreateCore } from './helm.exec'; +import { failed, Errorable } from './errorable'; +import { symbolAt, containmentChain, findKeyPath, FoundKeyPath } from './helm.symbolProvider'; + +interface Context { + readonly fs: FS; + readonly host: Host; + readonly projectPath: string; +} + +interface Chart { + name: string; + path: string; +} + +export async function convertToTemplate(fs: FS, host: Host, projectPath: string, target: vscode.Uri | ResourceNode | undefined): Promise { + const context = { fs, host, projectPath }; + const activeDocument = host.activeDocument(); + if (isKubernetesExplorerResourceNode(target)) { + // it's a k8s explorer click + const uri = target.uri('yaml'); + const yaml = (await host.readDocument(uri)).getText(); + addChart(context, yaml); + } else if (target) { + // it's a file explorer click + addChartFrom(context, target.fsPath); + } else if (activeDocument) { + addChart(context, activeDocument.getText()); + } else { + host.showErrorMessage("This command requires a YAML file open or selected in the Explorer."); + } +} + +async function addChartFrom(context: Context, fsPath: string): Promise { + const yaml = context.fs.readFileSync(fsPath, 'utf-8'); + await addChart(context, yaml); +} + +async function addChart(context: Context, resourceYaml: string): Promise { + // TODO: check text is valid YAML + const chart = await pickOrCreateChart(context); + if (!chart) { + return; + } + + const template = yaml.safeLoad(resourceYaml); + templatise(template); + + // TODO: offer a default + const templateName = await context.host.showInputBox({ prompt: "Name for the new template" }); + if (!templateName) { + return; + } + + const templateFile = path.join(chart.path, "templates", templateName + ".yaml"); + // TODO: check if file already exists + + const templateYaml = yaml.safeDump(template); // the parse-dump cycle can change the indentation of collections - is this an issue? + const templateText = fixYamlValueQuoting(templateYaml); + context.fs.writeFileSync(templateFile, templateText); + + await context.host.showDocument(vscode.Uri.file(templateFile)); +} + +enum QuoteMode { + None, + Double, +} + +const NAME_EXPRESSION = '{{ template "fullname" . }}'; +const CHART_LABEL_EXPRESSION = '{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}'; + +const QUOTE_CONTROL_INFO = [ + { text: NAME_EXPRESSION, mode: QuoteMode.None }, + { text: CHART_LABEL_EXPRESSION, mode: QuoteMode.Double }, +]; + +function templatise(template: any): void { + ensureMetadata(template); + cleanseMetadata(template.metadata); + + template.metadata.name = NAME_EXPRESSION; + template.metadata.labels.chart = CHART_LABEL_EXPRESSION; + + delete template.status; +} + +function ensureMetadata(template: any): void { + template.metadata = template.metadata || {}; + template.metadata.labels = template.metadata.labels || {}; +} + +const ANNOTATIONS_TO_STRIP = [ + 'kubectl.kubernetes.io/last-applied-configuration' +]; + +function cleanseMetadata(metadata: any): void { + delete metadata.clusterName; + delete metadata.creationTimestamp; + delete metadata.deletionTimestamp; + delete metadata.generation; + delete metadata.generateName; + delete metadata.namespace; + delete metadata.resourceVersion; + delete metadata.selfLink; + delete metadata.uid; + + if (metadata.annotations) { + for (const annotation of ANNOTATIONS_TO_STRIP) { + delete metadata.annotations[annotation]; + } + } +} + +function chartsInProject(context: Context): Chart[] { + const fs = context.fs; + return subdirectories(fs, context.projectPath) + .filter((d) => fs.existsSync(path.join(d, "Chart.yaml"))) + .map((d) => ({ name: path.basename(d), path: d })); +} + +function subdirectories(fs: FS, directory: string): string[] { + const immediate = fs.dirSync(directory) + .map((e) => path.join(directory, e)) + .filter((e) => fs.statSync(e).isDirectory()); + const indirect = immediate.map((d) => subdirectories(fs, d)); + return immediate.concat(...indirect); +} + +async function pickOrCreateChart(context: Context): Promise { + // TODO: refactor helmexec.pickChart so we can leverage it here + const charts = chartsInProject(context); + switch (charts.length) { + case 0: + return await createChart(context); + case 1: + return charts[0]; + default: + const chartPicks = charts.map((c) => ({ label: c.name, chart: c })); + const pick = await context.host.showQuickPick(chartPicks, { placeHolder: 'Select chart to add the new template to' }); + return pick ? pick.chart : undefined; + } +} + +function fixYamlValueQuoting(yamlText: string): string { + let text = yamlText; + for (const expr of QUOTE_CONTROL_INFO) { + const q = expr.mode === QuoteMode.Double ? '"' : ''; + text = text.replace(`'${expr.text}'`, `${q}${expr.text}${q}`); + } + return text; +} + +async function createChart(context: Context): Promise { + const createResult = await helmCreateCore("No chart found. Enter name of the chart to create.", "mychart"); + + if (!createResult) { + return undefined; + } + + if (failed(createResult)) { + context.host.showErrorMessage(createResult.error[0]); + return; + } + + return createResult.result; +} + +export interface ValueInsertion { + readonly document: vscode.TextDocument; + readonly keyPath: string[]; + readonly edit: vscode.TextEdit; +} + +export interface TextEdit { + readonly document: vscode.TextDocument; + readonly edits: vscode.TextEdit[]; +} + +export async function convertToParameter(fs: FS, host: Host, document: vscode.TextDocument, selection: vscode.Selection): Promise> { + const helmSymbols = await getHelmSymbols(document); + if (helmSymbols.length === 0) { + return { succeeded: false, error: ['Active document is not a Helm template'] }; + } + + const property = symbolAt(selection.anchor, helmSymbols); + if (!property || property.kind !== vscode.SymbolKind.Constant) { + return { succeeded: false, error: ['Selection is not a YAML field'] }; + } + + const templateName = path.parse(document.fileName).name; + + const valueLocation = property.location.range; + const valueText = document.getText(valueLocation); + const valueSymbolContainmentChain = containmentChain(property, helmSymbols); + + if (valueSymbolContainmentChain.length === 0) { + return { succeeded: false, error: ['Cannot locate property name'] }; + } + + const rawKeyPath = [templateName, valueSymbolContainmentChain[0].name]; + const keyPath = rawKeyPath.map(sanitiseForGoTemplate); + + const insertParamEdit = await addEntryToValuesFile(fs, host, document, keyPath, valueText); + if (failed(insertParamEdit)) { + return { succeeded: false, error: insertParamEdit.error }; + } + + const keyReference = insertParamEdit.result.keyPath.join('.'); + const replaceValueWithParamRef = new vscode.TextEdit(valueLocation, `{{ .Values.${keyReference} }}`); + + const appliedEdits = await applyEdits( + { document: document, edits: [replaceValueWithParamRef] }, + { document: insertParamEdit.result.document, edits: [insertParamEdit.result.edit] } + ); + if (!appliedEdits) { + return { succeeded: false, error: ['Unable to update the template and/or values file'] }; + } + + return { succeeded: true, result: insertParamEdit.result }; +} + +async function applyEdits(...edits: TextEdit[]): Promise { + const wsEdit = new vscode.WorkspaceEdit(); + for (const e of edits) { + wsEdit.set(e.document.uri, e.edits); + } + return await vscode.workspace.applyEdit(wsEdit); +} + +async function getHelmSymbols(document: vscode.TextDocument): Promise { + const sis: any = await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', document.uri); + + if (sis && sis.length) { + return sis; + } + + return []; +} + +async function addEntryToValuesFile(fs: FS, host: Host, template: vscode.TextDocument, keyPath: string[], value: string): Promise> { + const valuesYamlPath = path.normalize(path.join(path.dirname(template.fileName), '..', 'values.yaml')); + if (!fs.existsSync(valuesYamlPath)) { + fs.writeFileSync(valuesYamlPath, ''); + } + + const valuesYamlDoc = await host.readDocument(vscode.Uri.file(valuesYamlPath)); + const valuesYamlAst = await getHelmSymbols(valuesYamlDoc); + + const whatWeHave = findCreatableKeyPath(keyPath, valuesYamlAst); + const insertion = addToYaml(valuesYamlDoc, valuesYamlAst, whatWeHave.found, whatWeHave.remaining, value); + return { succeeded: true, result: insertion }; +} + +function findCreatableKeyPath(keyPath: string[], ast: vscode.SymbolInformation[]): FoundKeyPath { + const foundPath = findKeyPath(keyPath, ast); + if (foundPath.remaining.length > 0) { + return foundPath; + } + + const disambiguatingPath = disambiguateKeyPath(keyPath); + const foundDisambiguatingPath = findKeyPath(disambiguatingPath, ast); + if (foundDisambiguatingPath.remaining.length > 0) { + return foundDisambiguatingPath; + } + + return findCreatableKeyPathBySuffixing(keyPath, ast, 1); +} + +function disambiguateKeyPath(keyPath: string[]): string[] { + const path = keyPath.slice(0, keyPath.length - 1); + const disambiguatedFinal = keyPath.join('_'); + path.push(disambiguatedFinal); + return path; +} + +function findCreatableKeyPathBySuffixing(keyPath: string[], ast: vscode.SymbolInformation[], suffix: number): FoundKeyPath { + const path = keyPath.slice(0, keyPath.length - 1); + const suffixedFinal = keyPath[keyPath.length - 1] + suffix.toString(); + path.push(suffixedFinal); + + const foundPath = findKeyPath(path, ast); + if (foundPath.remaining.length > 0) { + return foundPath; + } + + return findCreatableKeyPathBySuffixing(keyPath, ast, suffix + 1); +} + +function addToYaml(document: vscode.TextDocument, ast: vscode.SymbolInformation[], parent: vscode.SymbolInformation, keys: string[], value: string): ValueInsertion { + const eol = document.eol === vscode.EndOfLine.CRLF ? '\r\n' : '\n'; + if (parent) { + // TODO: do we need to handle the possibility of a parent node without any child nodes? + const before = firstChild(document, parent, ast); + return insertBefore(document, before, keys, value, eol); + } else { + // TODO: handle the case where the document is entirely empty + const before = firstChild(document, undefined, ast); + return insertBefore(document, before, keys, value, eol); + } +} + +function firstChild(document: vscode.TextDocument, parent: vscode.SymbolInformation, ast: vscode.SymbolInformation[]): vscode.SymbolInformation | undefined { + const isDescendant = parent ? + (n: vscode.SymbolInformation) => parent.location.range.contains(n.location.range) : + (n: vscode.SymbolInformation) => true; + const linearPos = (p: vscode.Position) => document.offsetAt(p); + + return _.chain(ast) + .filter(isDescendant) + .filter((n) => n !== parent) + .filter((n) => n.kind === vscode.SymbolKind.Field) + .orderBy([(n) => linearPos(n.location.range.start), (n) => linearPos(n.location.range.end)]) + .first() + .value(); +} + +function insertBefore(document: vscode.TextDocument, element: vscode.SymbolInformation, keys: string[], value: string, eol: string): ValueInsertion { + const insertAt = element ? lineStart(element.location.range.start) : document.positionAt(0); + const indent = indentLevel(element); + const text = makeTree(indent, keys, value, eol); + const edit = vscode.TextEdit.insert(insertAt, text); + return { document: document, keyPath: keys, edit: edit }; +} + +function lineStart(pos: vscode.Position): vscode.Position { + return new vscode.Position(pos.line, 0); +} + +function indentLevel(element: vscode.SymbolInformation): number { + return element.location.range.start.character; +} + +function makeTree(indentLevel: number, keys: string[], value: string, eol: string): string { + if (keys.length < 1) { + return ''; + } + + const indent = ' '.repeat(indentLevel); + if (keys.length === 1) { + return `${indent}${keys[0]}: ${value}${eol}`; + } + + const subtree = makeTree(indentLevel + 2, keys.slice(1), value, eol); + return `${indent}${keys[0]}:${eol}${subtree}`; +} + +function sanitiseForGoTemplate(s: string): string { + return s.replace(/-./g, (h) => h.substring(1).toUpperCase()); +} diff --git a/src/helm.exec.ts b/src/helm.exec.ts index d1b7cc666..3430bd39a 100644 --- a/src/helm.exec.ts +++ b/src/helm.exec.ts @@ -14,7 +14,7 @@ import * as helm from './helm'; import { showWorkspaceFolderPick } from './hostutils'; import { shell as sh, ShellResult } from './shell'; import { K8S_RESOURCE_SCHEME, HELM_RESOURCE_AUTHORITY } from './kuberesources.virtualfs'; -import { Errorable } from './errorable'; +import { Errorable, failed } from './errorable'; import { parseLineOutput } from './outputUtils'; import { sleep } from './sleep'; import { currentNamespace } from './kubectlUtils'; @@ -94,21 +94,37 @@ export function helmDepUp() { } export async function helmCreate(): Promise { + const createResult = await helmCreateCore("Chart name", "mychart"); + + if (createResult && failed(createResult)) { + vscode.window.showErrorMessage(createResult.error[0]); + } +} + +export async function helmCreateCore(prompt: string, sampleName: string): Promise | undefined> { const folder = await showWorkspaceFolderPick(); if (!folder) { - return; + return undefined; } - vscode.window.showInputBox({ - prompt: "chart name", - placeHolder: "mychart" - }).then((name) => { - const fullpath = filepath.join(folder.uri.fsPath, name); - helmExec(`create "${fullpath}"`, (code, out, err) => { - if (code !== 0) { - vscode.window.showErrorMessage(err); - } - }); + + const name = await vscode.window.showInputBox({ + prompt: prompt, + placeHolder: sampleName }); + + if (!name) { + return undefined; + } + + const fullpath = filepath.join(folder.uri.fsPath, name); + + const sr = await helmExecAsync(`create "${fullpath}"`); + + if (sr.code !== 0) { + return { succeeded: false, error: [ sr.stderr ] }; + } + + return { succeeded: true, result: { name: name, path: fullpath } }; } // helmLint runs the Helm linter on a chart within your project. diff --git a/src/helm.symbolProvider.ts b/src/helm.symbolProvider.ts new file mode 100644 index 000000000..575575bea --- /dev/null +++ b/src/helm.symbolProvider.ts @@ -0,0 +1,165 @@ +import * as vscode from 'vscode'; +import * as yp from 'yaml-ast-parser'; +import * as _ from 'lodash'; + +export class HelmDocumentSymbolProvider implements vscode.DocumentSymbolProvider { + provideDocumentSymbols(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.ProviderResult { + return this.provideDocumentSymbolsImpl(document, token); + } + + async provideDocumentSymbolsImpl(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const fakeText = document.getText().replace(/{{[^}]*}}/g, (s) => encodeWithTemplateMarkers(s)); + const root = yp.safeLoad(fakeText); + const syms: vscode.SymbolInformation[] = []; + walk(root, '', document, document.uri, syms); + return syms; + } +} + +// These MUST be the same lengths as the strings they replace +// ('{{', '}}' and '"'") - we rely on the text ranges staying +// the same in order to detect and substitute back the actual +// template expression. +const ENCODE_TEMPLATE_START = 'AA'; +const ENCODE_TEMPLATE_END = 'ZZ'; +const ENCODE_TEMPLATE_QUOTE = 'Q'; + +// This is pretty horrible, but the YAML parser can't handle embedded Go template +// expressions. So we transform Go template expressions to (reasonably) distinctive +// strings with the EXACT SAME position and length, run the YAML parser, then when we +// construct the Helm AST, if we see such a string we check back to the original YAML +// document to fix it up if necessary. +function encodeWithTemplateMarkers(s: string): string { + return s.replace(/{{/g, ENCODE_TEMPLATE_START) + .replace(/}}/g, ENCODE_TEMPLATE_END) + .replace(/"/g, ENCODE_TEMPLATE_QUOTE); +} + +function hasEncodedTemplateMarkers(s: string): boolean { + return (s.startsWith(ENCODE_TEMPLATE_START) && s.endsWith(ENCODE_TEMPLATE_END)) + || (s.startsWith('"' + ENCODE_TEMPLATE_START) && s.endsWith(ENCODE_TEMPLATE_END + '"')); +} + +export interface FoundKeyPath { + readonly found: vscode.SymbolInformation | undefined; + readonly remaining: string[]; +} + +export function findKeyPath(keyPath: string[], sis: vscode.SymbolInformation[]): FoundKeyPath { + return findKeyPathAcc(keyPath, sis, undefined); +} + +function findKeyPathAcc(keyPath: string[], sis: vscode.SymbolInformation[], acc: vscode.SymbolInformation | undefined): FoundKeyPath { + const parentSym = findKey(keyPath[0], sis); + if (!parentSym) { + return { found: acc, remaining: keyPath }; + } + if (keyPath.length === 1) { + return { found: parentSym, remaining: [] }; + } + const childSyms = sis.filter((s) => parentSym.location.range.contains(s.location.range)); + return findKeyPathAcc(keyPath.slice(1), childSyms, parentSym); +} + +function findKey(key: string, sis: vscode.SymbolInformation[]): vscode.SymbolInformation | undefined { + const fields = sis.filter((si) => si.kind === vscode.SymbolKind.Field && si.name === key); + if (fields.length === 0) { + return undefined; + } + return outermost(fields); +} + +function outermost(sis: vscode.SymbolInformation[]): vscode.SymbolInformation { + return _.maxBy(sis, (s) => containmentChain(s, sis)); +} + +export function containmentChain(s: vscode.SymbolInformation, sis: vscode.SymbolInformation[]): vscode.SymbolInformation[] { + const containers = sis.filter((si) => si.kind === vscode.SymbolKind.Field) + .filter((si) => si.location.range.contains(s.location.range)) + .filter((si) => si !== s); + if (containers.length === 0) { + return []; + } + const nextUp = minimalSymbol(containers); + const fromThere = containmentChain(nextUp, sis); + return [nextUp, ...fromThere]; +} + +export function symbolAt(position: vscode.Position, sis: vscode.SymbolInformation[]): vscode.SymbolInformation | undefined { + const containers = sis.filter((si) => si.location.range.contains(position)); + if (containers.length === 0) { + return undefined; + } + return minimalSymbol(containers); +} + +function minimalSymbol(sis: vscode.SymbolInformation[]): vscode.SymbolInformation { + let m = sis[0]; + for (const si of sis) { + if (m.location.range.contains(si.location.range)) { + m = si; + } + } + return m; +} + +function symbolInfo(node: yp.YAMLNode, containerName: string, d: vscode.TextDocument, uri: vscode.Uri): vscode.SymbolInformation { + const start = node.startPosition; + const end = node.endPosition; + const loc = new vscode.Location(uri, new vscode.Range(d.positionAt(start), d.positionAt(end))); + switch (node.kind) { + case yp.Kind.ANCHOR_REF: + return new vscode.SymbolInformation(`ANCHOR_REF`, vscode.SymbolKind.Variable, containerName, loc); + case yp.Kind.INCLUDE_REF: + return new vscode.SymbolInformation(`INCLUDE_REF`, vscode.SymbolKind.Variable, containerName, loc); + case yp.Kind.MAP: + const m = node as yp.YamlMap; + return new vscode.SymbolInformation(`{map}`, vscode.SymbolKind.Variable, containerName, loc); + case yp.Kind.MAPPING: + const mp = node as yp.YAMLMapping; + return new vscode.SymbolInformation(`${mp.key.rawValue}`, vscode.SymbolKind.Field, containerName, loc); + case yp.Kind.SCALAR: + const sc = node as yp.YAMLScalar; + const isPossibleTemplateExpr = hasEncodedTemplateMarkers(sc.rawValue); + const realValue = isPossibleTemplateExpr ? d.getText(loc.range) : sc.rawValue; + const isTemplateExpr = (realValue.startsWith('{{') && realValue.endsWith('}}')) + || (realValue.startsWith('"{{') && realValue.endsWith('}}"')); + const symbolKind = isTemplateExpr ? vscode.SymbolKind.Object : vscode.SymbolKind.Constant; + return new vscode.SymbolInformation(realValue, symbolKind, containerName, loc); + case yp.Kind.SEQ: + const s = node as yp.YAMLSequence; + return new vscode.SymbolInformation(`[seq]`, vscode.SymbolKind.Variable, containerName, loc); + } + return new vscode.SymbolInformation(`###_YAML_UNEXPECTED_###`, vscode.SymbolKind.Variable, containerName, loc); +} + +function walk(node: yp.YAMLNode, containerName: string, d: vscode.TextDocument, uri: vscode.Uri, syms: vscode.SymbolInformation[]) { + const sym = symbolInfo(node, containerName, d, uri); + syms.push(sym); + switch (node.kind) { + case yp.Kind.ANCHOR_REF: + return; + case yp.Kind.INCLUDE_REF: + return; + case yp.Kind.MAP: + const m = node as yp.YamlMap; + for (const mm of m.mappings) { + walk(mm, sym.name, d, uri, syms); + } + return; + case yp.Kind.MAPPING: + const mp = node as yp.YAMLMapping; + if (mp.value) { + walk(mp.value, sym.name, d, uri, syms); + } + return; + case yp.Kind.SCALAR: + return; + case yp.Kind.SEQ: + const s = node as yp.YAMLSequence; + for (const y of s.items) { + walk(y, sym.name, d, uri, syms); + } + return; + } +} diff --git a/src/host.ts b/src/host.ts index 47e82cf7a..3a762c88d 100644 --- a/src/host.ts +++ b/src/host.ts @@ -13,6 +13,9 @@ export interface Host { createTerminal(name?: string, shellPath?: string, shellArgs?: string[]): vscode.Terminal; onDidCloseTerminal(listener: (e: vscode.Terminal) => any): vscode.Disposable; onDidChangeConfiguration(listener: (ch: vscode.ConfigurationChangeEvent) => any): vscode.Disposable; + activeDocument(): vscode.TextDocument | undefined; + showDocument(uri: vscode.Uri): Promise; + readDocument(uri: vscode.Uri): Promise; } export const host: Host = { @@ -25,7 +28,10 @@ export const host: Host = { createTerminal : createTerminal, onDidCloseTerminal : onDidCloseTerminal, onDidChangeConfiguration : onDidChangeConfiguration, - showInputBox : showInputBox + showInputBox : showInputBox, + activeDocument : activeDocument, + showDocument : showDocument, + readDocument : readDocument }; function showInputBox(options: vscode.InputBoxOptions, token?: vscode.CancellationToken): Thenable { @@ -94,3 +100,23 @@ function onDidCloseTerminal(listener: (e: vscode.Terminal) => any): vscode.Dispo function onDidChangeConfiguration(listener: (e: vscode.ConfigurationChangeEvent) => any): vscode.Disposable { return vscode.workspace.onDidChangeConfiguration(listener); } + +function activeDocument(): vscode.TextDocument | undefined { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + return activeEditor.document; + } + return undefined; +} + +async function showDocument(uri: vscode.Uri): Promise { + const document = await vscode.workspace.openTextDocument(uri); + if (document) { + await vscode.window.showTextDocument(document); + } + return document; +} + +async function readDocument(uri: vscode.Uri): Promise { + return await vscode.workspace.openTextDocument(uri); +} diff --git a/src/kuberesources.virtualfs.ts b/src/kuberesources.virtualfs.ts index ccd57ada7..c729757ea 100644 --- a/src/kuberesources.virtualfs.ts +++ b/src/kuberesources.virtualfs.ts @@ -12,6 +12,14 @@ export const K8S_RESOURCE_SCHEME = "k8smsx"; export const KUBECTL_RESOURCE_AUTHORITY = "loadkubernetescore"; export const HELM_RESOURCE_AUTHORITY = "helmget"; +export function kubefsUri(namespace: string | null, value: string, outputFormat: string): Uri { + const docname = `${value.replace('/', '-')}.${outputFormat}`; + const nonce = new Date().getTime(); + const nsquery = namespace ? `ns=${namespace}&` : ''; + const uri = `${K8S_RESOURCE_SCHEME}://${KUBECTL_RESOURCE_AUTHORITY}/${docname}?${nsquery}value=${value}&_=${nonce}`; + return Uri.parse(uri); +} + export class KubernetesResourceVirtualFileSystemProvider implements FileSystemProvider { constructor(private readonly kubectl: Kubectl, private readonly host: Host, private readonly rootPath: string) { }