From 2fe94627c2b79e36acb9ed6c7f2b26d370d4da66 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Thu, 31 Oct 2019 15:41:54 -0700 Subject: [PATCH] Ask user to update to latest schema --- extension.bundle.ts | 3 +- src/AzureRMTools.ts | 95 ++++++++++++++++++++++++++++++++++++--- src/DeploymentTemplate.ts | 15 ++++--- src/constants.ts | 4 ++ src/schemas.ts | 45 +++++++++++++++++++ src/supported.ts | 13 +----- test/schemas.test.ts | 56 +++++++++++++++++++++++ 7 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 src/schemas.ts create mode 100644 test/schemas.test.ts diff --git a/extension.bundle.ts b/extension.bundle.ts index cffb38f4e..df3f84462 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -49,7 +49,7 @@ export { LanguageServerState } from "./src/languageclient/startArmLanguageServer export { ParameterDefinition } from "./src/ParameterDefinition"; export { IReferenceSite, PositionContext } from "./src/PositionContext"; export { ReferenceList } from "./src/ReferenceList"; -export { containsArmSchema, isArmSchema } from './src/supported'; +export { containsArmSchema, getPreferredSchema, isArmSchema } from './src/schemas'; export { ScopeContext, TemplateScope } from "./src/TemplateScope"; export { FunctionSignatureHelp } from "./src/TLE"; export { JsonOutlineProvider, shortenTreeLabel } from "./src/Treeview"; @@ -73,3 +73,4 @@ export { Language }; export { basic }; export { Utilities }; export { TLE }; + diff --git a/src/AzureRMTools.ts b/src/AzureRMTools.ts index c042a7c99..ee8770a8b 100644 --- a/src/AzureRMTools.ts +++ b/src/AzureRMTools.ts @@ -11,16 +11,18 @@ import * as vscode from "vscode"; import { AzureUserInput, callWithTelemetryAndErrorHandling, callWithTelemetryAndErrorHandlingSync, createTelemetryReporter, IActionContext, registerCommand, registerUIExtensionVariables, TelemetryProperties } from "vscode-azureextensionui"; import { uninstallDotnet } from "./acquisition/dotnetAcquisition"; import * as Completion from "./Completion"; -import { configKeys, configPrefix, expressionsDiagnosticsCompletionMessage, expressionsDiagnosticsSource, languageId, outputWindowName } from "./constants"; +import { configKeys, configPrefix, expressionsDiagnosticsCompletionMessage, expressionsDiagnosticsSource, languageId, outputWindowName, storageKeys } from "./constants"; import { DeploymentTemplate } from "./DeploymentTemplate"; import { ext } from "./extensionVariables"; import { Histogram } from "./Histogram"; -import * as Hover from "./Hover"; +import * as Hover from './Hover'; import { IncorrectArgumentsCountIssue } from "./IncorrectArgumentsCountIssue"; +import * as Json from "./JSON"; import * as language from "./Language"; import { startArmLanguageServer, stopArmLanguageServer } from "./languageclient/startArmLanguageServer"; import { PositionContext } from "./PositionContext"; -import * as Reference from "./ReferenceList"; +import { ReferenceList } from "./ReferenceList"; +import { getPreferredSchema } from "./schemas"; import { getFunctionParamUsage } from "./signatureFormatting"; import { Stopwatch } from "./Stopwatch"; import { armDeploymentDocumentSelector, mightBeDeploymentTemplate } from "./supported"; @@ -117,6 +119,7 @@ export class AzureRMTools { actionContext.telemetry.properties.fileExt = path.extname(document.fileName); assert(document); + const editor: vscode.TextEditor | undefined = vscode.window.activeTextEditor; const stopwatch = new Stopwatch(); stopwatch.start(); @@ -159,8 +162,14 @@ export class AzureRMTools { return; } - // Template for template opened + // Telemetry for template opened this.reportTemplateOpenedTelemetry(document, deploymentTemplate, stopwatch); + + // No guarantee that active editor is the one we're processing, ignore if not + if (editor && editor.document === document) { + // Are they using an older schema? Ask to update. + this.queryUseNewerSchema(editor, deploymentTemplate); + } } this.reportDeploymentTemplateErrors(document, deploymentTemplate); @@ -255,6 +264,80 @@ export class AzureRMTools { }); } + private queryUseNewerSchema(editor: vscode.TextEditor, deploymentTemplate: DeploymentTemplate): void { + // tslint:disable-next-line: strict-boolean-expressions + const schemaValue: Json.StringValue | null = deploymentTemplate.schemaValue; + // tslint:disable-next-line: strict-boolean-expressions + const schemaUri: string | undefined = deploymentTemplate.schemaUri || undefined; + const preferredSchemaUri: string | undefined = schemaUri && getPreferredSchema(schemaUri); + const document = editor.document; + const documentUri = document.uri.toString(); + + if (preferredSchemaUri && schemaValue) { + // tslint:disable-next-line: no-floating-promises + callWithTelemetryAndErrorHandling('queryUpdateSchema', async (actionContext: IActionContext): Promise => { + actionContext.telemetry.properties.currentSchema = schemaUri; + actionContext.telemetry.properties.preferredSchema = preferredSchemaUri; + + // tslint:disable-next-line: strict-boolean-expressions + const dontAskFiles = ext.context.globalState.get(storageKeys.dontAskAboutSchemaFiles) || []; + if (dontAskFiles.includes(documentUri)) { + actionContext.telemetry.properties.isInDontAskList = 'true'; + return; + } + + const yes: vscode.MessageItem = { title: "Use latest" }; + const notNow: vscode.MessageItem = { title: "Not now" }; + const neverForThisFile: vscode.MessageItem = { title: "Not for this file" }; + + const response = await ext.ui.showWarningMessage( + `Would you like to use the latest schema for deployment template "${path.basename(document.uri.path)}" (note: some tools may be unable to process the latest schema)?`, + { + learnMoreLink: "https://aka.ms/vscode-azurearmtools-updateschema" + }, + yes, + notNow, + neverForThisFile + ); + actionContext.telemetry.properties.response = response.title; + + switch (response.title) { + case yes.title: + await this.replaceSchema(editor, deploymentTemplate, schemaValue.unquotedValue, preferredSchemaUri); + return; + case notNow.title: + return; + case neverForThisFile.title: + dontAskFiles.push(documentUri); + await ext.context.globalState.update(storageKeys.dontAskAboutSchemaFiles, dontAskFiles); + break; + default: + assert("queryAddSchema: Unexpected response"); + break; + } + }); + } + } + + private async replaceSchema(editor: vscode.TextEditor, deploymentTemplate: DeploymentTemplate, previousSchema: string, newSchema: string): Promise { + // The document might have changed since we asked, so find the $schema again + const currentTemplate = new DeploymentTemplate(editor.document.getText(), `current ${deploymentTemplate.documentId}`); + const currentSchemaValue: Json.StringValue | null = currentTemplate.schemaValue; + if (currentSchemaValue && currentSchemaValue.unquotedValue === previousSchema) { + const range = getVSCodeRangeFromSpan(currentTemplate, currentSchemaValue.unquotedSpan); + await editor.edit(edit => { + // Replace $schema value + edit.replace(range, newSchema); + }); + + // Select what we just replaced + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.Default); + } else { + throw new Error("The document has changed, the $schema was not replaced."); + } + } + private getCompletedDiagnostic(): vscode.Diagnostic | undefined { if (ext.addCompletedDiagnostic) { // Add a diagnostic to indicate expression validation is done (for testing) @@ -532,7 +615,7 @@ export class AzureRMTools { const locationUri: vscode.Uri = vscode.Uri.parse(deploymentTemplate.documentId); const positionContext: PositionContext = deploymentTemplate.getContextFromDocumentLineAndColumnIndexes(position.line, position.character); - const references: Reference.ReferenceList | null = positionContext.getReferences(); + const references: ReferenceList | null = positionContext.getReferences(); if (references && references.length > 0) { actionContext.telemetry.properties.referenceType = references.kind; @@ -586,7 +669,7 @@ export class AzureRMTools { const result: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); const context: PositionContext = deploymentTemplate.getContextFromDocumentLineAndColumnIndexes(position.line, position.character); - const referenceList: Reference.ReferenceList | null = context.getReferences(); + const referenceList: ReferenceList | null = context.getReferences(); if (referenceList) { // When trying to rename a parameter or variable reference inside of a TLE, the // textbox that pops up when you press F2 contains more than just the variable diff --git a/src/DeploymentTemplate.ts b/src/DeploymentTemplate.ts index 477a83a49..72130e9c2 100644 --- a/src/DeploymentTemplate.ts +++ b/src/DeploymentTemplate.ts @@ -14,7 +14,7 @@ import * as language from "./Language"; import { ParameterDefinition } from "./ParameterDefinition"; import { PositionContext } from "./PositionContext"; import { ReferenceList } from "./ReferenceList"; -import { isArmSchema } from "./supported"; +import { isArmSchema } from "./schemas"; import { ScopeContext, TemplateScope } from "./TemplateScope"; import * as TLE from "./TLE"; import { UserFunctionNamespaceDefinition } from "./UserFunctionNamespaceDefinition"; @@ -49,7 +49,7 @@ export class DeploymentTemplate { private _topLevelVariableDefinitions: CachedValue = new CachedValue(); private _topLevelParameterDefinitions: CachedValue = new CachedValue(); - private _schemaUri: CachedValue = new CachedValue(); + private _schema: CachedValue = new CachedValue(); /** * Create a new DeploymentTemplate object. @@ -154,12 +154,17 @@ export class DeploymentTemplate { } public get schemaUri(): string | null { - return this._schemaUri.getOrCacheValue(() => { + const schema = this.schemaValue; + return schema ? schema.unquotedValue : null; + } + + public get schemaValue(): Json.StringValue | null { + return this._schema.getOrCacheValue(() => { const value: Json.ObjectValue | null = Json.asObjectValue(this._jsonParseResult.value); if (value) { - const schema: Json.Value | null = Json.asStringValue(value.getPropertyValue("$schema")); + const schema: Json.StringValue | null = Json.asStringValue(value.getPropertyValue("$schema")); if (schema) { - return schema.toString(); + return schema; } } diff --git a/src/constants.ts b/src/constants.ts index 725d06fe1..53b551138 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -33,6 +33,10 @@ export namespace configKeys { export const langServerPath = 'languageServer.path'; } +export namespace storageKeys { + export const dontAskAboutSchemaFiles = 'dontAskAboutSchemaFiles'; +} + // For testing: We create a diagnostic with this message during testing to indicate when all (expression) diagnostics have been calculated export const diagnosticsCompletePrefix = "Diagnostics complete: "; export const expressionsDiagnosticsCompletionMessage = diagnosticsCompletePrefix + expressionsDiagnosticsSource; diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 000000000..d0b147ba1 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,45 @@ + +// ---------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ---------------------------------------------------------------------------- + +export const containsArmSchemaRegexString = + `https?:\/\/schema\.management\.azure\.com\/schemas\/[^"\/]+\/[a-zA-Z]*[dD]eploymentTemplate\.json#?`; +export const containsArmSchemaRegex = new RegExp(containsArmSchemaRegexString, 'i'); +export const isArmSchemaRegex = new RegExp(`^${containsArmSchemaRegexString}$`, 'i'); + +export function containsArmSchema(json: string): boolean { + return !!json && containsArmSchemaRegex.test(json); +} + +export function isArmSchema(json: string | undefined | null): boolean { + return !!json && isArmSchemaRegex.test(json); +} + +// Current root schemas: +// Resource group: +// https://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json# +// https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json# +// https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json# +// Subscription: +// https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json# +// Management Group: +// https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json# +// Tenant: +// https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json# + +/** + * Given a schema, returns the recommended one (most recent) for the same scope. + * If the schema is not valid or there is no better schema, returns undefined + */ +export function getPreferredSchema(schema: string): string | undefined { + // Being very specific about which old schemas we match, because if they come out with a newer schema, we don't + // want to start suggesting an older one just because we don't recognize the new one + + if (schema.match(/https?:\/\/schema\.management\.azure\.com\/schemas\/(2014-04-01-preview|2015-01-01)\/deploymentTemplate.json#?/i)) { + return "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"; + } + + // No other scopes currently have more recent schemas + return undefined; +} diff --git a/src/supported.ts b/src/supported.ts index 637058a24..a303dc61e 100644 --- a/src/supported.ts +++ b/src/supported.ts @@ -4,15 +4,12 @@ import { Position, Range, TextDocument, workspace } from "vscode"; import { configKeys, configPrefix, languageId } from "./constants"; +import { containsArmSchema } from "./schemas"; export const armDeploymentDocumentSelector = [ { language: languageId, scheme: 'file' } ]; -const containsArmSchemaRegexString = - `https?:\/\/schema\.management\.azure\.com\/schemas\/[^"\/]+\/[a-zA-Z]*[dD]eploymentTemplate\.json#?`; -const containsArmSchemaRegex = new RegExp(containsArmSchemaRegexString, 'i'); -const isArmSchemaRegex = new RegExp(`^${containsArmSchemaRegexString}$`, 'i'); const maxLinesToDetectSchemaIn = 500; function isJsonOrJsoncLangId(textDocument: TextDocument): boolean { @@ -57,11 +54,3 @@ export function mightBeDeploymentTemplate(textDocument: TextDocument): boolean { return false; } - -export function containsArmSchema(json: string): boolean { - return !!json && containsArmSchemaRegex.test(json); -} - -export function isArmSchema(json: string | undefined | null): boolean { - return !!json && isArmSchemaRegex.test(json); -} diff --git a/test/schemas.test.ts b/test/schemas.test.ts new file mode 100644 index 000000000..daf2f7f8b --- /dev/null +++ b/test/schemas.test.ts @@ -0,0 +1,56 @@ +// ---------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ---------------------------------------------------------------------------- + +// tslint:disable:max-func-body-length no-http-string + +import * as assert from "assert"; +import { getPreferredSchema } from "../extension.bundle"; + +const mostRecentRGSchema = "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"; + +suite("Schemas", () => { + suite("getPreferredSchema", () => { + function createTest(schema: string, expectedPreferred: string | undefined): void { + // tslint:disable-next-line: strict-boolean-expressions + test(schema || "(empty)", () => { + const preferred: string | undefined = getPreferredSchema(schema); + assert.equal(preferred, expectedPreferred); + }); + } + + // Unrecognized schemas + createTest("", undefined); + createTest("://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", undefined); + createTest("2://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", undefined); + createTest("https://schema.management.azure.com/schema/2014-04-01-preview/deploymentTemplate.json#", undefined); // misspelled /schemas/ + createTest("https://schemas.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", undefined); // misspelled /schema./management + + // Newer schemas shouldn't trip and therefore recommend an older schema + createTest("https://schema.management.azure.com/schemas/2020-01-01/deploymentTemplate.json#", undefined); + + // https://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json# - prefer newer + createTest("https://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", mostRecentRGSchema); + createTest("http://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", mostRecentRGSchema); + createTest("https://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json", mostRecentRGSchema); + createTest("HTTPS://SCHEMA.Management.Azure.Com/Schemas/2014-04-01-PREVIEW/DeploymentTemplate.json#", mostRecentRGSchema); + + // https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json# - prefer newer + createTest("https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", mostRecentRGSchema); + createTest("http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", mostRecentRGSchema); + createTest("https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json", mostRecentRGSchema); + createTest("HTTPS://SCHEMA.Management.Azure.Com/Schemas/2015-01-01/DeploymentTemplate.json#", mostRecentRGSchema); + + // https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json# - already newest, should return undefined + createTest("https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", undefined); + + // https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json# - already newest + createTest("https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", undefined); + + // https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json# - already newest + createTest("https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#", undefined); + + // https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json# - already newest + createTest("https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#", undefined); + }); +});