diff --git a/.eslintignore b/.eslintignore
index 1fb48e00..0d92de43 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -4,3 +4,4 @@ node_modules
yarn.lock
package-lock.json
public
+configeditor/build
diff --git a/.gcloudignore b/.gcloudignore
index aac997e5..8317d3e5 100644
--- a/.gcloudignore
+++ b/.gcloudignore
@@ -24,5 +24,6 @@ test/
.prettier*
*release-please*
*.md
+configeditor/
#!include:.gitignore
diff --git a/.gitignore b/.gitignore
index b8bdde10..ad36998b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,5 @@ kubernetes/**/resourcegroup.yaml
# Terratest
.test-data
+
+configeditor/build
diff --git a/README.md b/README.md
index 2b7f8579..5e460e1d 100644
--- a/README.md
+++ b/README.md
@@ -166,6 +166,9 @@ deployment type, but the mechanism for configuration differs slightly:
* [Cloud Functions](terraform/cloud-functions/README.md#configuration)
* [Google Kubernetes Engine (GKE)](terraform/gke/README.md#building-and-deploying-the-autoscaler-services)
+There is also a [browser-based configuration file editor and a command line
+configuration file validator][configeditor].
+
## Licensing
```lang-none
@@ -208,6 +211,7 @@ support channels.
[cloud-logging]: https://cloud.google.com/logging
[compute-capacity]: https://cloud.google.com/spanner/docs/compute-capacity#compute_capacity
[code-of-conduct]: code-of-conduct.md
+[configeditor]: configeditor/README.md
[contributing-guidelines]: contributing.md
[gke]: https://cloud.google.com/kubernetes-engine
[new-issue]: https://github.com/cloudspannerecosystem/autoscaler/issues/new
diff --git a/configeditor/README.md b/configeditor/README.md
new file mode 100644
index 00000000..e01b3c77
--- /dev/null
+++ b/configeditor/README.md
@@ -0,0 +1,51 @@
+
+
+
+ Validating editor for Autoscaler configuration.
+
+ Home
+ ·
+ Scaler component
+ ·
+ Poller component
+ ·
+ Forwarder component
+ ·
+ Terraform configuration
+ ·
+ Monitoring
+
Copy/Paste your YAML or JSON autoscaler config in the editor below.
+The configuration will automatically be validated and any errors shown
++ Powered by jsoneditoronline.org +
+ + diff --git a/configeditor/index.mjs b/configeditor/index.mjs new file mode 100644 index 00000000..37168ec5 --- /dev/null +++ b/configeditor/index.mjs @@ -0,0 +1,175 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Support scripts for browser-based config editor. + */ + +// eslint-disable-next-line +import { + JSONEditor, + createAjvValidator, + renderJSONSchemaEnum, + renderValue, +} from "./build/vanilla-jsoneditor/standalone.js"; +import schema from "./build/autoscaler-config.schema.json" with { type: "json" }; +import * as JsYaml from "./build/js-yaml/dist/js-yaml.mjs"; + +/** @typedef {import("vanilla-jsoneditor").Content} Content */ + +/** @type {JSONEditor} */ +let editor; + +/** + * Checks if the JSON is valid, and if so, copy it to the YAML textarea. + * + * If it is not, but _is_ valid YAML, convert it to JSON and update both the + * JSON editor and the YAML textarea. + * + * @param {Content} content + * @param {Content} previousContent + * @param {{ + * contentErrors: import("vanilla-jsoneditor").ContentErrors | null, + * patchResult: import("vanilla-jsoneditor").JSONPatchResult | null + * }} changeStatus + */ +function jsonEditorChangeHandler(newcontent, previousContent, changeStatus) { + const yamlTextarea = document.getElementById("yamlequivalent"); + + if (changeStatus?.contentErrors?.parseError) { + console.log( + "jsonEditorChangeHandler - got JSON parsing errors %o", + changeStatus.contentErrors, + ); + if (newcontent.text?.search("\nkind: ConfigMap\n") >= 0) { + // Check if it is valid YAML text to see if we need to convert it back + // to JSON. + try { + const configMap = JsYaml.load(newcontent.text); + if ( + configMap && + configMap.kind === "ConfigMap" && + configMap.data && + Object.values(configMap.data)[0] + ) { + // The autoscaler ConfigMap data is YAML stored as text in a YAML, + // so we need to re-parse the data object. + const configMapData = JsYaml.load(Object.values(configMap.data)[0]); + console.log("got yaml configMap data object: %o", configMapData); + + // Asynchronously update the content with the parsed configmap data. + // This is because JSON editor likes to finish the onchange before + // anything else happens! + setTimeout(() => { + /** @type {Content} */ + const content = { json: configMapData }; + editor.updateProps({ + content, + mode: "text", + selection: null, + }); + editor.refresh(); + // Trigger refresh of YAML textarea. + updateYamlWithJsonContent(content); + }, 100); + return; + } + } catch (e) { + console.log("not valid yaml " + e); + } + } + // Some other unparsable JSON. + yamlTextarea.setAttribute("disabled", "true"); + } else { + // Got valid JSON, even if it might not be valid Autoscaler config, we + // update the YAML version. + updateYamlWithJsonContent(newcontent); + } +} + +/** + * Converts the content from JSONEditor to YAML and stores it in the YAML + * textarea. + * + * @param {Content} content + */ +function updateYamlWithJsonContent(content) { + const yamlTextarea = document.getElementById("yamlequivalent"); + yamlTextarea.removeAttribute("disabled"); + const json = content.text ? JSON.parse(content.text) : content.json; + + const configMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: "autoscaler-config", + namespace: "spanner-autoscaler", + }, + data: { + // Autoscaler configmap.data is YAML as text. + "autoscaler-config.yaml": JsYaml.dump(json, { lineWidth: -1 }), + }, + }; + yamlTextarea.value = JsYaml.dump(configMap, { lineWidth: -1 }); +} + +/** + * Handles addling rendering of Schema enums in JSONEditor. + * + * @param {import("vanilla-jsoneditor").RenderValueProps} props + * @return {import("vanilla-jsoneditor").RenderValueComponentDescription[]} + */ +function onRenderValue(props) { + return renderJSONSchemaEnum(props, schema) || renderValue(props); +} + +/** @type {Content} */ +const EXAMPLE_CONFIG = { + json: [ + { + $comment: "Sample autoscaler config", + projectId: "my-project", + instanceId: "my-instance", + units: "NODES", + minSize: 1, + maxSize: 3, + stateDatabase: { + name: "firestore", + }, + scalerPubSubTopic: "projects/my-project/topics/scaler-topic", + }, + ], +}; + +/** Handles DOMContentLoaded event. */ +function onDOMContentLoaded() { + editor = new JSONEditor({ + target: document.getElementById("jsoneditor"), + props: { + content: EXAMPLE_CONFIG, + mode: "text", + schema, + indentation: 2, + validator: createAjvValidator({ schema }), + onChange: jsonEditorChangeHandler, + onRenderValue, + }, + }); + updateYamlWithJsonContent(EXAMPLE_CONFIG); + document.getElementById("loading").style.display = "none"; +} + +document.addEventListener("DOMContentLoaded", onDOMContentLoaded, false); diff --git a/package.json b/package.json index 5642e35b..0fabd404 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "eslint-fix": "eslint --fix .", "format": "prettier --write .", "install-all": "npm install --save", - "markdown-link-check": "find . -name '*.md' -not \\( -path './node_modules/*' -o -path '*/.terraform/*' -o -path './CHANGELOG.md' -prune \\) -print0 | xargs --null markdown-link-check --config markdown-link-checker.json --quiet", - "mdlint": "markdownlint '**/*.md' --config .mdl.json --ignore '**/node_modules/**' --ignore 'code-of-conduct.md' --ignore 'CHANGELOG.md'", + "markdown-link-check": "find . -name '*.md' -not \\( -path './node_modules/*' -o -path './configeditor/build/*' -o -path '*/.terraform/*' -o -path './CHANGELOG.md' -prune \\) -print0 | xargs --null markdown-link-check --config markdown-link-checker.json --quiet", + "mdlint": "markdownlint '**/*.md' --config .mdl.json --ignore '**/node_modules/**' --ignore '**/build/**' --ignore 'code-of-conduct.md' --ignore 'CHANGELOG.md'", "poller-job": "node -e \"require('./src/poller/index').main()\"", "prepare": "{ git rev-parse --is-inside-work-tree >/dev/null 2>/dev/null && test \"$NODE_ENV\" != production -a \"$CI\" != true && husky ; } || true", "prettier": "prettier --write .", @@ -29,7 +29,8 @@ "typecheck": "tsc --project jsconfig.json --maxNodeModuleJsDepth 0 --noEmit", "unified-job": "node -e \"require('./src/unifiedScaler').main()\"", "update-all": "npm update -S", - "validate-config-file": "node -e \"require('./src/poller/poller-core/config-validator').main()\" -- " + "validate-config-file": "node -e \"require('./src/poller/poller-core/config-validator').main()\" -- ", + "start-configeditor-server": "cd configeditor && ./build-configeditor.sh --quiet && npm exec http-server -- -a 127.0.0.1 " }, "dependencies": { "@google-cloud/firestore": "^7.10.0",