Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ask user to update to latest schema #368

Merged
merged 1 commit into from
Nov 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion extension.bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -73,3 +73,4 @@ export { Language };
export { basic };
export { Utilities };
export { TLE };

95 changes: 89 additions & 6 deletions src/AzureRMTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> => {
actionContext.telemetry.properties.currentSchema = schemaUri;
actionContext.telemetry.properties.preferredSchema = preferredSchemaUri;

// tslint:disable-next-line: strict-boolean-expressions
const dontAskFiles = ext.context.globalState.get<string[]>(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<void> {
// 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)
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/DeploymentTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,7 +49,7 @@ export class DeploymentTemplate {
private _topLevelVariableDefinitions: CachedValue<IVariableDefinition[]> = new CachedValue<IVariableDefinition[]>();
private _topLevelParameterDefinitions: CachedValue<ParameterDefinition[]> = new CachedValue<ParameterDefinition[]>();

private _schemaUri: CachedValue<string | null> = new CachedValue<string | null>();
private _schema: CachedValue<Json.StringValue | null> = new CachedValue<Json.StringValue | null>();

/**
* Create a new DeploymentTemplate object.
Expand Down Expand Up @@ -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;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 1 addition & 12 deletions src/supported.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
56 changes: 56 additions & 0 deletions test/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});