Files
qbit-scripting/base/src/main/java/com/kingsrook/qbits/scripting/ScriptingQBitProducer.java
T
f0d695ec97
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Failing after 7s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Failing after 6s
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
chore: preserve paused QBit work and document remaining issues
Save the current implementation and README, status, and known-issue documentation at the user-requested stop. No completed release or deployment is claimed.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
2026-11-04 10:45:28 -06:00

316 lines
16 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.
*/
package com.kingsrook.qbits.scripting;
import java.util.List;
import java.util.function.Consumer;
import com.kingsrook.qbits.scripting.service.IsolatedSourceGuard;
import com.kingsrook.qbits.scripting.service.ScriptingService;
import com.kingsrook.qbits.scripting.service.WarmRuntimeService;
import com.kingsrook.qbits.scripting.worker.WarmWorkerMaintenance;
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.scripts.ScriptTableCustomizer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Registers the scripting-owned tables (builds, artifacts, publications,
** bindings, execution history) and creates the instance's ScriptingService. The
** project and draft-source tables are the existing core script tables and must
** already be defined. Every scripting table is read-protected by scripting.view
** and writable only by the service itself (see ScriptingTablePermission).
*******************************************************************************/
public class ScriptingQBitProducer implements QBitProducer
{
private ScriptingQBitConfig config;
private Consumer<QTableMetaData> tableEnricher = table ->
{
// default: nothing; RDBMS hosts add their backend details here
};
/***************************************************************************
** Supply the host configuration.
***************************************************************************/
public ScriptingQBitProducer withConfig(ScriptingQBitConfig value)
{
config = value;
return (this);
}
/***************************************************************************
** Let the host add backend-specific table details, for example RDBMS table
** names, to each scripting table.
***************************************************************************/
public ScriptingQBitProducer withTableEnricher(Consumer<QTableMetaData> value)
{
tableEnricher = value;
return (this);
}
/***************************************************************************
** Validate prerequisites, add the tables, and install the service.
***************************************************************************/
@Override
public void produce(QInstance instance, String namespace) throws QException
{
if(config == null)
{
throw new QException("Scripting requires a configuration");
}
if(namespace != null && !namespace.isBlank() && !"scripting".equals(namespace))
{
throw new QException("The scripting QBit supports the scripting namespace only");
}
config.validate(instance);
if(!instance.getTable("script").getFields().containsKey("executionMode"))
{
throw new QException("Scripting requires the reviewed core isolation marker metadata");
}
requireSystemReadableProjectLocks(instance);
for(TableCustomizers hook : List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD, TableCustomizers.PRE_DELETE_RECORD))
{
appendGuard(instance.getTable("script"), hook, new QCodeReference(ScriptTableCustomizer.class));
appendGuard(instance.getTable("scriptRevision"), hook, new QCodeReference(IsolatedSourceGuard.class));
appendGuard(instance.getTable("scriptRevisionFile"), hook, new QCodeReference(IsolatedSourceGuard.class));
}
for(String name : List.of("scriptRevision", "scriptRevisionFile"))
{
QTableMetaData source = instance.getTable(name);
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(source, instance).clone();
source.setPermissionRules(rules.withCustomPermissionChecker(SourceAggregatePermission.wrap(rules.getCustomPermissionChecker())));
source.withoutCapabilities(Capability.TABLE_COUNT, Capability.QUERY_STATS);
}
appendGuard(instance.getTable("scriptRevision"), TableCustomizers.POST_QUERY_RECORD, new QCodeReference(IsolatedSourceGuard.class));
appendGuard(instance.getTable("scriptRevisionFile"), TableCustomizers.POST_QUERY_RECORD, new QCodeReference(IsolatedSourceGuard.class));
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-scripting").withVersion(version()).withNamespace("scripting").withConfig(config));
instance.addTable(table(ScriptingTables.BUILD, "Script builds",
integer("script_id"), integer("script_revision_id"), string("source_sha256"), text("manifest_json"), string("manifest_attestation"), string("toolchain_id"), string("status"),
text("diagnostics_json"), string("requested_by"), dateTime("created_at")));
instance.addTable(table(ScriptingTables.ARTIFACT, "Script artifacts",
integer("build_id"), integer("script_id"), integer("version"), string("source_sha256"), string("toolchain_id"), string("entry_point"), string("coordinator_id"),
text("bundle"), string("bundle_sha256"), integer("size_bytes"), text("source_map"), string("attestation"), dateTime("created_at"))
.withUniqueKey(new UniqueKey("build_id")).withUniqueKey(new UniqueKey("script_id", "version")));
instance.addTable(table(ScriptingTables.PUBLICATION, "Script publications",
integer("script_id"), integer("version"), integer("build_id"), integer("artifact_id"), string("published_by"), dateTime("published_at"), string("note"))
.withUniqueKey(new UniqueKey("script_id", "version")));
instance.addTable(table(ScriptingTables.BINDING, "Script hook bindings",
string("name"), string("table_name"), string("event"), integer("publication_id"), text("grants_json"), text("patchable_fields"), integer("enabled"), string("active_slot"),
string("created_by"), dateTime("created_at"))
.withUniqueKey(new UniqueKey("active_slot")));
instance.addTable(table(ScriptingTables.EXECUTION, "Script executions",
string("operation_id").withMaxLength(64), string("kind"), integer("build_id"), integer("binding_id"), integer("publication_id"), integer("artifact_id"), string("actor_id"), string("event"),
string("status"), integer("duration_ms"), string("message"), string("result_action"), dateTime("created_at")));
instance.addTable(table(ScriptingTables.IMPORT, "Script source imports",
integer("script_id"), integer("script_revision_id"), string("repository"), string("commit_sha"), string("subdirectory"),
string("source_sha256"), string("imported_by"), dateTime("created_at"))
.withUniqueKey(new UniqueKey("script_revision_id")));
ScriptingAuthoringProcesses.register(instance);
ScriptingNavigation.register(instance);
config.setService(new ScriptingService(config.getRuntime()));
if(config.getRuntime().getWorker() instanceof WarmWorkerMaintenance)
{
instance.withRuntimeService(new QCodeReference(WarmRuntimeService.class));
HealthChecksMetaData.ofOrWithNew(instance).withCheck(new HealthCheckMetaData().withName("scripting.warm")
.withLabel("Scripting warm code").withComponent("scripting").withIncludeInReadiness(true)
.withFailureThreshold(1).withIntervalSeconds(10).withTimeoutMs(5000).withProbeCache(false)
.withCheck(new QCodeReference(WarmRuntimeService.class)));
}
}
/***************************************************************************
** The QBit alone reads and writes its project tables as the QQQ system session,
** after the caller has been authorized. A host may lock those tables with
** native record security (for example by tenant); QQQ lets the system session
** through only for a key type that defines an all-access key. Without it every
** internal read would silently see no rows and even the owner's authoring would
** fail as "not found", so refuse the configuration with the exact remedy.
***************************************************************************/
private static void requireSystemReadableProjectLocks(QInstance instance) throws QException
{
for(String tableName : List.of("script", "scriptRevision", "scriptRevisionFile"))
{
for(var lock : flatten(instance.getTable(tableName).getRecordSecurityLocks()))
{
var keyType = lock.getSecurityKeyType() == null ? null : instance.getSecurityKeyType(lock.getSecurityKeyType());
if(keyType != null && (keyType.getAllAccessKeyName() == null || keyType.getAllAccessKeyName().isBlank()))
{
throw new QException("Scripting reads its project tables as the QQQ system session, but the record-security lock on table " + tableName
+ " uses key type " + keyType.getName() + " which defines no all-access key. Configure QSecurityKeyType.withAllAccessKeyName for it.");
}
}
}
}
/***************************************************************************
** Leaf locks of a possibly nested lock list.
***************************************************************************/
private static List<com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock> flatten(
List<com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock> locks)
{
List<com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock> leaves = new java.util.ArrayList<>();
for(var lock : locks == null ? List.<com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock>of() : locks)
{
if(lock instanceof com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock multi)
{
leaves.addAll(flatten(multi.getLocks()));
}
else
{
leaves.add(lock);
}
}
return (leaves);
}
/***************************************************************************
** Keep host behavior and apply integrity checks to its final record values.
***************************************************************************/
private static void appendGuard(QTableMetaData table, TableCustomizers hook, QCodeReference guard)
{
QCodeReference prior = table.getCustomizer(hook.getRole()).orElse(null);
if(prior == null)
{
table.withCustomizer(hook, guard);
}
else
{
table.getCustomizers().put(hook.getRole(), MultiCustomizer.of(prior, guard));
}
}
/***************************************************************************
** The build-produced JAR version; IDE/test class directories say so honestly.
***************************************************************************/
private static String version()
{
String version = ScriptingQBitProducer.class.getPackage().getImplementationVersion();
return (version == null || version.isBlank() ? "development" : version);
}
/***************************************************************************
** One scripting table: integer id, the given fields, all non-editable in the
** UI, protected by the shared table permission rule.
***************************************************************************/
private QTableMetaData table(String name, String label, QFieldMetaData... fields)
{
QTableMetaData table = new QTableMetaData()
.withName(name)
.withLabel(label)
.withBackendName(config.getBackendName())
.withPrimaryKeyField("id")
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(ScriptingTablePermission.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(ScriptingTablePermission.class)))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id").withIsEditable(false));
for(QFieldMetaData field : fields)
{
table.withField(field);
}
tableEnricher.accept(table);
return (table);
}
/***************************************************************************
** Read-only string field with a readable label.
***************************************************************************/
private static QFieldMetaData string(String name)
{
return (field(name, QFieldType.STRING));
}
/***************************************************************************
** Read-only long text field.
***************************************************************************/
private static QFieldMetaData text(String name)
{
return (field(name, QFieldType.TEXT));
}
/***************************************************************************
** Read-only integer field.
***************************************************************************/
private static QFieldMetaData integer(String name)
{
return (field(name, QFieldType.INTEGER));
}
/***************************************************************************
** Read-only timestamp field.
***************************************************************************/
private static QFieldMetaData dateTime(String name)
{
return (field(name, QFieldType.DATE_TIME));
}
/***************************************************************************
** Build a field whose label is derived from its snake_case name.
***************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
String label = name.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + name.substring(1).replace('_', ' ');
return (new QFieldMetaData(name, type).withLabel(label).withIsEditable(false));
}
}