From 703186adf1f92c64dec130df5f856d292129fe9d Mon Sep 17 00:00:00 2001 From: Andrew Henry Date: Thu, 10 Oct 2024 15:09:39 -0700 Subject: [PATCH] Add script to lock object sub-tree and fix object locking bugs (#7855) * Script for locking an object tree * Show lock button if locked * Do not allow properties editing of locked objects * Remove package-lock.json * Added p-debounce * Allow duplication of locked objects * Better user feedback * Add semaphores to prevent file handle exhaustion * Leverage official Apache Couch library - nano. Clean up dependencies. Default to environment variables for couch config. Simplify batching mechanism to make it synchronouse * Added lock user attribution * Remove unused code * Modify open script for adding auth design doc * Added script for creating auth design doc * Add css class for disallow unlock * Add user attribution to lock button * Fix import * Typo * User it was locked by, not current user. Wow. * Closes #7877 - Front-end sanding and shimming: displays instead of button when domainObject.disallowUnlock. * Fixed bug where lock is shown even if object is not locked --------- Co-authored-by: Charles Hacskaylo Co-authored-by: Jesse Mazzella --- package-lock.json | 94 +++++++++ package.json | 3 +- src/plugins/duplicate/DuplicateAction.js | 6 +- .../formActions/EditPropertiesAction.js | 2 +- .../properties/PropertiesComponent.vue | 9 + .../persistence/couch/scripts/lockObjects.mjs | 191 ++++++++++++++++++ .../persistence/couch/setup-couchdb.sh | 18 ++ src/ui/layout/BrowseBar.vue | 59 +++++- 8 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 src/plugins/persistence/couch/scripts/lockObjects.mjs diff --git a/package-lock.json b/package-lock.json index dc6193af5f0..c82e60d1a72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "moment": "2.30.1", "moment-duration-format": "2.3.2", "moment-timezone": "0.5.41", + "nano": "10.1.4", "npm-run-all2": "6.1.2", "nyc": "15.1.0", "painterro": "1.2.87", @@ -2463,6 +2464,12 @@ "@mdn/browser-compat-data": "^5.2.34" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "node_modules/axe-core": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.4.tgz", @@ -2472,6 +2479,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-loader": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.0.tgz", @@ -3012,6 +3030,18 @@ "node": ">=0.1.90" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-values": { "version": "3.6.4", "resolved": "https://registry.npmjs.org/comma-separated-values/-/comma-separated-values-3.6.4.tgz", @@ -4102,6 +4132,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5755,6 +5794,20 @@ "node": ">=8.0.0" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7896,6 +7949,35 @@ "multicast-dns": "cli.js" } }, + "node_modules/nano": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/nano/-/nano-10.1.4.tgz", + "integrity": "sha512-bJOFIPLExIbF6mljnfExXX9Cub4W0puhDjVMp+qV40xl/DBvgKao7St4+6/GB6EoHZap7eFnrnx4mnp5KYgwJA==", + "dev": true, + "dependencies": { + "axios": "^1.7.4", + "node-abort-controller": "^3.1.1", + "qs": "^6.13.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/nano/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -7935,6 +8017,12 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9185,6 +9273,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", diff --git a/package.json b/package.json index 35863a7efc0..b52c369f190 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "moment": "2.30.1", "moment-duration-format": "2.3.2", "moment-timezone": "0.5.41", + "nano": "10.1.4", "npm-run-all2": "6.1.2", "nyc": "15.1.0", "painterro": "1.2.87", @@ -156,4 +157,4 @@ "keywords": [ "nasa" ] -} \ No newline at end of file +} diff --git a/src/plugins/duplicate/DuplicateAction.js b/src/plugins/duplicate/DuplicateAction.js index 0f26dabe63b..6e905f42776 100644 --- a/src/plugins/duplicate/DuplicateAction.js +++ b/src/plugins/duplicate/DuplicateAction.js @@ -109,8 +109,9 @@ class DuplicateAction { let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier); let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier); let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier); + const isLocked = parentCandidate.locked === true; - if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) { + if (isLocked || !this.openmct.objects.isPersistable(parentCandidate.identifier)) { return false; } @@ -139,10 +140,9 @@ class DuplicateAction { const parentType = parent && this.openmct.types.get(parent.type); const child = objectPath[0]; const childType = child && this.openmct.types.get(child.type); - const locked = child.locked ? child.locked : parent && parent.locked; const isPersistable = this.openmct.objects.isPersistable(child.identifier); - if (locked || !isPersistable) { + if (!isPersistable) { return false; } diff --git a/src/plugins/formActions/EditPropertiesAction.js b/src/plugins/formActions/EditPropertiesAction.js index 713e800c768..dc3d3ee0bd4 100644 --- a/src/plugins/formActions/EditPropertiesAction.js +++ b/src/plugins/formActions/EditPropertiesAction.js @@ -45,7 +45,7 @@ class EditPropertiesAction extends PropertiesAction { const definition = this._getTypeDefinition(object.type); const persistable = this.openmct.objects.isPersistable(object.identifier); - return persistable && definition && definition.creatable; + return persistable && definition && definition.creatable && !object.locked; } invoke(objectPath) { diff --git a/src/plugins/inspectorViews/properties/PropertiesComponent.vue b/src/plugins/inspectorViews/properties/PropertiesComponent.vue index 77e3c837e78..8773fd82a61 100644 --- a/src/plugins/inspectorViews/properties/PropertiesComponent.vue +++ b/src/plugins/inspectorViews/properties/PropertiesComponent.vue @@ -96,6 +96,8 @@ export default { const createdTimestamp = this.domainObject.created; const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER; const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER; + const locked = this.domainObject.locked; + const lockedBy = this.domainObject.lockedBy ?? UNKNOWN_USER; const modifiedTimestamp = this.domainObject.modified ? this.domainObject.modified : this.domainObject.created; @@ -148,6 +150,13 @@ export default { }); } + if (locked === true) { + details.push({ + name: 'Locked By', + value: lockedBy + }); + } + if (version) { details.push({ name: 'Version', diff --git a/src/plugins/persistence/couch/scripts/lockObjects.mjs b/src/plugins/persistence/couch/scripts/lockObjects.mjs new file mode 100644 index 00000000000..f7edf69459d --- /dev/null +++ b/src/plugins/persistence/couch/scripts/lockObjects.mjs @@ -0,0 +1,191 @@ +import http from 'http'; +import nano from 'nano'; +import { parseArgs } from 'util'; + +const COUCH_URL = process.env.OPENMCT_COUCH_URL || 'http://127.0.0.1:5984'; +const COUCH_DB_NAME = process.env.OPENMCT_DATABASE_NAME || 'openmct'; + +const { + values: { couchUrl, database, lock, unlock, startObjectKeystring, user, pass } +} = parseArgs({ + options: { + couchUrl: { + type: 'string', + default: COUCH_URL + }, + database: { + type: 'string', + short: 'd', + default: COUCH_DB_NAME + }, + lock: { + type: 'boolean', + short: 'l' + }, + unlock: { + type: 'boolean', + short: 'u' + }, + startObjectKeystring: { + type: 'string', + short: 'o', + default: 'mine' + }, + user: { + type: 'string' + }, + pass: { + type: 'string' + } + } +}); + +const BATCH_SIZE = 100; +const SOCKET_POOL_SIZE = 100; + +const locked = lock === true; +console.info(`Connecting to ${couchUrl}/${database}`); +console.info(`${locked ? 'Locking' : 'Unlocking'} all children of ${startObjectKeystring}`); + +const poolingAgent = new http.Agent({ + keepAlive: true, + maxSockets: SOCKET_POOL_SIZE +}); + +const db = nano({ + url: couchUrl, + requestDefaults: { + agent: poolingAgent + } +}).use(database); +db.auth(user, pass); + +if (!unlock && !lock) { + throw new Error('Either -l or -u option is required'); +} + +const startObjectIdentifier = keystringToIdentifier(startObjectKeystring); +const documentBatch = []; +const alreadySeen = new Set(); +let updatedDocumentCount = 0; + +await processObjectTreeFrom(startObjectIdentifier); +//Persist final batch +await persistBatch(); +console.log(`Processed ${updatedDocumentCount} documents`); + +function processObjectTreeFrom(parentObjectIdentifier) { + //1. Fetch document for identifier; + return fetchDocument(parentObjectIdentifier) + .then(async (document) => { + if (document !== undefined) { + if (!alreadySeen.has(document._id)) { + alreadySeen.add(document._id); + //2. Lock or unlock object + document.model.locked = locked; + document.model.disallowUnlock = locked; + + if (locked) { + document.model.lockedBy = 'script'; + } else { + delete document.model.lockedBy; + } + //3. Push document to a batch + documentBatch.push(document); + //4. Persist batch if necessary, reporting failures + await persistBatchIfNeeded(); + //5. Repeat for each composee + const composition = document.model.composition || []; + return Promise.all( + composition.map((composee) => { + return processObjectTreeFrom(composee); + }) + ); + } + } + }) + .catch((error) => { + console.log(`Error ${error}`); + }); +} + +async function fetchDocument(identifierOrKeystring) { + let keystring; + if (typeof identifierOrKeystring === 'object') { + keystring = identifierToKeystring(identifierOrKeystring); + } else { + keystring = identifierOrKeystring; + } + + try { + const document = await db.get(keystring); + + return document; + } catch (error) { + return undefined; + } +} + +function persistBatchIfNeeded() { + if (documentBatch.length >= BATCH_SIZE) { + return persistBatch(); + } else { + //Noop - batch is not big enough yet + return; + } +} + +async function persistBatch() { + try { + const localBatch = [].concat(documentBatch); + + //Immediately clear the shared batch array. This asynchronous process is non-blocking, and + //we don't want to try and persist the same batch multiple times while we are waiting for + //the subsequent bulk operation to complete. + updatedDocumentCount += documentBatch.length; + + documentBatch.splice(0, documentBatch.length); + const response = await db.bulk({ docs: localBatch }); + + if (response instanceof Array) { + response.forEach((r) => { + console.info(JSON.stringify(r)); + }); + } else { + console.info(JSON.stringify(response)); + } + } catch (error) { + if (error instanceof Array) { + error.forEach((e) => console.error(JSON.stringify(e))); + } else { + console.error(`${error.statusCode} - ${error.reason}`); + } + } +} + +function keystringToIdentifier(keystring) { + const tokens = keystring.split(':'); + if (tokens.length === 2) { + return { + namespace: tokens[0], + key: tokens[1] + }; + } else { + return { + namespace: '', + key: tokens[0] + }; + } +} + +function identifierToKeystring(identifier) { + if (typeof identifier === 'string') { + return identifier; + } else if (typeof identifier === 'object') { + if (identifier.namespace) { + return `${identifier.namespace}:${identifier.key}`; + } else { + return identifier.key; + } + } +} diff --git a/src/plugins/persistence/couch/setup-couchdb.sh b/src/plugins/persistence/couch/setup-couchdb.sh index 18d81768830..88ae9f5323a 100755 --- a/src/plugins/persistence/couch/setup-couchdb.sh +++ b/src/plugins/persistence/couch/setup-couchdb.sh @@ -160,6 +160,24 @@ add_index_and_views() { echo "Unable to create annotation_keystring_index" echo $response fi + + # Add auth database for locked objects + response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/auth \ + --header 'Content-Type: application/json' \ + --data '{ + "_id": "_design/auth", + "language": "javascript", + "validate_doc_update": "function (newDoc, oldDoc, userCtx) { if (userCtx.roles.indexOf('\''_admin'\'') !== -1) { return; } else if (oldDoc === null) { return; } else if (oldDoc.model.type === '\''timer'\'' || oldDoc.model.type === '\''notebook'\'' || oldDoc.model.type === '\''restricted-notebook'\'') { if (oldDoc.model.name !== newDoc.model.name) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; } } else if (oldDoc.model.locked === true && oldDoc.model.disallowUnlock === true) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; }}" + }') + + if [[ $response =~ "\"ok\":true" ]]; then + echo "Successfully created _design/auth design document for locked objects" + elif [[ $response =~ "\"error\":\"conflict\"" ]]; then + echo "_design/auth already exists, skipping creation" + else + echo "Unable to create _design/auth" + echo $response + fi } # Main script execution diff --git a/src/ui/layout/BrowseBar.vue b/src/ui/layout/BrowseBar.vue index 013682a0f77..f9ea2147325 100644 --- a/src/ui/layout/BrowseBar.vue +++ b/src/ui/layout/BrowseBar.vue @@ -36,7 +36,7 @@ ref="objectName" class="l-browse-bar__object-name c-object-label__name" :class="{ 'c-input-inline': isPersistable }" - :contenteditable="isPersistable" + :contenteditable="isNameEditable" @blur="updateName" @keydown.enter.prevent @keyup.enter.prevent="updateNameOnEnterKeyPress" @@ -78,7 +78,7 @@ > + +