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 <span> instead of button when domainObject.disallowUnlock.

* Fixed bug where lock is shown even if object is not locked

---------

Co-authored-by: Charles Hacskaylo <charles.f.hacskaylo@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
This commit is contained in:
Andrew Henry 2024-10-10 15:09:39 -07:00 committed by GitHub
parent c43ef64733
commit 703186adf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 370 additions and 12 deletions

94
package-lock.json generated
View File

@ -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",

View File

@ -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"
]
}
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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',

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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 @@
></button>
<button
v-if="isViewEditable & !isEditing"
v-if="shouldShowLock"
:aria-label="lockedOrUnlockedTitle"
:title="lockedOrUnlockedTitle"
:class="{
@ -88,6 +88,13 @@
@click="toggleLock(!domainObject.locked)"
></button>
<span
v-else-if="domainObject?.locked"
class="icon-lock"
aria-label="Locked for editing, cannot be unlocked."
title="Locked for editing, cannot be unlocked."
></span>
<button
v-if="isViewEditable && !isEditing && !domainObject.locked"
class="l-browse-bar__actions__edit c-button c-button--major icon-pencil"
@ -180,6 +187,18 @@ export default {
};
},
computed: {
isNameEditable() {
return this.isPersistable && !this.domainObject.locked;
},
shouldShowLock() {
if (this.domainObject === undefined) {
return false;
}
if (this.domainObject.disallowUnlock) {
return false;
}
return this.domainObject.locked || (this.isViewEditable && !this.isEditing);
},
statusClass() {
return this.status ? `is-status--${this.status}` : '';
},
@ -253,11 +272,19 @@ export default {
return false;
},
lockedOrUnlockedTitle() {
let title;
if (this.domainObject.locked) {
return 'Locked for editing - click to unlock.';
if (this.domainObject.lockedBy !== undefined) {
title = `Locked for editing by ${this.domainObject.lockedBy}. `;
} else {
title = 'Locked for editing. ';
}
title += 'Click to unlock.';
} else {
return 'Unlocked for editing - click to lock.';
title = 'Unlocked for editing, click to lock.';
}
return title;
},
domainObjectName() {
return this.domainObject?.name ?? '';
@ -288,7 +315,6 @@ export default {
document.addEventListener('click', this.closeViewAndSaveMenu);
this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this);
window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway);
this.openmct.editor.on('isEditing', (isEditing) => {
this.isEditing = isEditing;
});
@ -421,8 +447,27 @@ export default {
this.actionCollection.off('update', this.updateActionItems);
delete this.actionCollection;
},
toggleLock(flag) {
this.openmct.objects.mutate(this.domainObject, 'locked', flag);
async toggleLock(flag) {
if (!this.domainObject.disallowUnlock) {
const wasTransactionActive = this.openmct.objects.isTransactionActive();
let transaction;
if (!wasTransactionActive) {
transaction = this.openmct.objects.startTransaction();
}
this.openmct.objects.mutate(this.domainObject, 'locked', flag);
const user = await this.openmct.user.getCurrentUser();
if (user !== undefined) {
this.openmct.objects.mutate(this.domainObject, 'lockedBy', user.id);
}
if (!wasTransactionActive) {
await transaction.commit();
this.openmct.objects.endTransaction();
}
}
},
setStatus(status) {
this.status = status;