@feltdb/core 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/application-development.d.ts +78 -0
- package/dist/application-development.d.ts.map +1 -0
- package/dist/application-development.js +153 -0
- package/dist/application-manifest.d.ts +6 -1
- package/dist/application-manifest.d.ts.map +1 -1
- package/dist/application-manifest.js +10 -3
- package/dist/canonical-application.d.ts +27 -0
- package/dist/canonical-application.d.ts.map +1 -0
- package/dist/canonical-application.js +46 -0
- package/dist/cli/ai-model-config.js +97 -0
- package/dist/cli/cli.js +1 -1
- package/dist/cli/commands.js +664 -120
- package/dist/cli/index.js +1 -1
- package/dist/cli/managed-environment.js +6 -0
- package/dist/cli/managed-project.js +6 -0
- package/dist/cli/proposal-client.js +63 -0
- package/dist/cli/publish-engine.js +165 -0
- package/dist/cli/source-sync.js +183 -0
- package/dist/create/cli.js +11 -2
- package/dist/create/create.js +38 -16
- package/dist/create/managed-account.js +82 -11
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +41 -1
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +84 -6
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
- package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
- package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
- package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
- package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
- package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
- package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +111 -3
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1960 -143
- package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
- package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
- package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
- package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +195 -13
- package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
- package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
- package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
- package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
- package/dist/create/template/default-project/README.md +57 -0
- package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
- package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
- package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
- package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
- package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
- package/dist/create/template/default-project/feltdb.flow +174 -0
- package/dist/create/template/default-project/src/App.tsx +20 -0
- package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
- package/dist/create/template/default-project/src/feltdb.ts +87 -0
- package/dist/create/template/default-project/src/index.tsx +14 -0
- package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
- package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
- package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
- package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
- package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
- package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
- package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
- package/dist/create/template/default-project/src/styles-application.css +21 -0
- package/dist/create/template/default-project/src/styles.css +1 -0
- package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
- package/dist/create/template/default-project/workflows/invitation.ts +30 -0
- package/dist/create/template/default-project/workflows/project-created.ts +2 -0
- package/dist/db.d.ts +49 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +77 -0
- package/dist/error-codes.d.ts +20 -1
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +25 -0
- package/dist/flowspec.d.ts +1 -0
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +66 -11
- package/dist/http-client.d.ts +135 -14
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +79 -31
- package/dist/http-db.d.ts +15 -2
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +113 -5
- package/dist/index-core.d.ts +5 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +5 -0
- package/dist/module-intent.d.ts +37 -0
- package/dist/module-intent.d.ts.map +1 -0
- package/dist/module-intent.js +81 -0
- package/dist/module.d.ts +76 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +61 -0
- package/dist/proposal.d.ts +161 -0
- package/dist/proposal.d.ts.map +1 -0
- package/dist/proposal.js +232 -0
- package/dist/studio/app.d.ts +5 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/ApplicationDesigner.d.ts +2 -1
- package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
- package/dist/studio/components/AskFeltDB.d.ts +21 -0
- package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
- package/dist/studio/components/ContractInspector.d.ts +7 -0
- package/dist/studio/components/ContractInspector.d.ts.map +1 -0
- package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
- package/dist/studio/components/ManagedInstancePanel.js +1 -5
- package/dist/studio/components/ModuleExplorer.d.ts +6 -0
- package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
- package/dist/studio/components/ProposalBrowser.d.ts +12 -0
- package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
- package/dist/studio/components/StateExplorer.d.ts +6 -7
- package/dist/studio/components/StateExplorer.d.ts.map +1 -1
- package/dist/studio/components/index.d.ts +4 -0
- package/dist/studio/components/index.d.ts.map +1 -1
- package/dist/studio/components/index.js +4 -4
- package/dist/studio/components-CNSNrmA9.js +2787 -0
- package/dist/studio/index.js +214 -157
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/index.d.ts +2 -0
- package/dist/studio/utils/index.d.ts.map +1 -1
- package/dist/studio/utils/index.js +14 -12
- package/dist/studio/utils/managed-instance.d.ts +0 -4
- package/dist/studio/utils/managed-instance.d.ts.map +1 -1
- package/dist/studio/utils/managed-instance.js +0 -4
- package/dist/studio/utils/proposal-api.d.ts +53 -0
- package/dist/studio/utils/proposal-api.d.ts.map +1 -0
- package/dist/studio/utils/proposal-api.js +91 -0
- package/dist/studio/utils/service-api.d.ts +81 -0
- package/dist/studio/utils/service-api.d.ts.map +1 -0
- package/dist/studio/utils/service-api.js +51 -0
- package/dist/studio-app/assets/dist-CCOk39Uc.js +1 -0
- package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-CSB6KVgw.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
- package/dist/studio-app/assets/index-Bncji3aN.js +43 -0
- package/dist/studio-app/assets/{index-C1GWyazR.css → index-yazaWMUN.css} +1 -1
- package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
- package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -0
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/local-development-authority.d.ts +3 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +124 -2
- package/package.json +1 -1
- package/dist/studio/components-Dxuhrv8_.js +0 -1832
- package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
- package/dist/studio-app/assets/index-B5tnmvSD.js +0 -28
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
function loadEnvironment(file) {
|
|
4
|
+
const values = {};
|
|
5
|
+
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
6
|
+
const line = raw.trim();
|
|
7
|
+
if (!line || line.startsWith('#'))
|
|
8
|
+
continue;
|
|
9
|
+
const separator = line.indexOf('=');
|
|
10
|
+
if (separator > 0)
|
|
11
|
+
values[line.slice(0, separator)] = line.slice(separator + 1);
|
|
12
|
+
}
|
|
13
|
+
return values;
|
|
14
|
+
}
|
|
15
|
+
function managedStateFile(projectDir) {
|
|
16
|
+
return path.join(projectDir, '.feltdb', 'managed.json');
|
|
17
|
+
}
|
|
3
18
|
async function responseJson(response, fallback) {
|
|
4
19
|
const body = await response.json().catch(() => ({}));
|
|
5
20
|
if (!response.ok)
|
|
@@ -43,26 +58,82 @@ export async function configureManagedAccount(options) {
|
|
|
43
58
|
// Provision through the account origin that issued the human session. The
|
|
44
59
|
// resulting key is still used against apiUrl, but setup must not require a
|
|
45
60
|
// browser session to be trusted by every managed data-plane hostname.
|
|
46
|
-
const
|
|
61
|
+
const controlKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
|
|
62
|
+
method: 'POST', headers,
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
id: `${options.applicationName}-control`,
|
|
65
|
+
namespace: application.namespace || options.namespace,
|
|
66
|
+
application_id: application.id,
|
|
67
|
+
scopes: [
|
|
68
|
+
'application:read', 'application:write', 'application:revision:create',
|
|
69
|
+
'application:revision:read', 'application:revision:promote',
|
|
70
|
+
'application:environment:read', 'application:environment:write',
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
}), 'Could not create the managed control-plane key');
|
|
74
|
+
if (!controlKey.secret)
|
|
75
|
+
throw new Error('Managed key service did not return the control-plane credential');
|
|
76
|
+
const dataKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
|
|
47
77
|
method: 'POST', headers,
|
|
48
78
|
body: JSON.stringify({
|
|
49
|
-
id: `${options.applicationName}-
|
|
50
|
-
namespace: options.namespace,
|
|
51
|
-
|
|
79
|
+
id: `${options.applicationName}-runtime`,
|
|
80
|
+
namespace: application.namespace || options.namespace,
|
|
81
|
+
application_id: application.id,
|
|
82
|
+
scopes: [
|
|
83
|
+
'application:read', 'application:revision:read',
|
|
84
|
+
'state:read', 'state:write', 'events:read',
|
|
85
|
+
'workflows:run', 'agents:run', 'capabilities:run',
|
|
86
|
+
],
|
|
52
87
|
}),
|
|
53
|
-
}), 'Could not create the managed application key');
|
|
54
|
-
if (!
|
|
55
|
-
throw new Error('Managed key service did not return
|
|
88
|
+
}), 'Could not create the managed application data key');
|
|
89
|
+
if (!dataKey.secret)
|
|
90
|
+
throw new Error('Managed key service did not return the application data credential');
|
|
91
|
+
try {
|
|
92
|
+
await responseJson(await request(`${apiUrl}/api/applications/${encodeURIComponent(application.id)}/revisions`, {
|
|
93
|
+
headers: { Authorization: `Bearer ${controlKey.secret}` },
|
|
94
|
+
}), 'Managed control-plane credential was created but is not accepted by the managed API');
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await Promise.allSettled([controlKey.id, dataKey.id].filter(Boolean).map(id => request(`${sessionOrigin}/api/keys/${encodeURIComponent(id)}`, { method: 'DELETE', headers })));
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
56
100
|
const environment = [
|
|
57
101
|
'# Generated by create-feltdb managed setup. Do not commit this file.',
|
|
58
102
|
`VITE_FELTDB_MANAGED_URL=${apiUrl}`,
|
|
59
|
-
`VITE_FELTDB_MANAGED_API_KEY=${key.secret}`,
|
|
60
|
-
`VITE_FELTDB_MANAGED_TENANT_ID=${tenant.id}`,
|
|
61
103
|
`VITE_FELTDB_MANAGED_APPLICATION_ID=${application.id}`,
|
|
62
|
-
|
|
63
|
-
|
|
104
|
+
'# Control-plane credential. Never prefix this with VITE_ or expose it to browser code.',
|
|
105
|
+
`FELTDB_MANAGED_CONTROL_API_KEY=${controlKey.secret}`,
|
|
106
|
+
'# Server/deployment data-plane credential. Never prefix this with VITE_.',
|
|
107
|
+
`FELTDB_TOKEN=${dataKey.secret}`,
|
|
64
108
|
'',
|
|
65
109
|
].join('\n');
|
|
66
110
|
fs.writeFileSync(path.join(options.projectDir, '.env.local'), environment, { mode: 0o600 });
|
|
111
|
+
fs.mkdirSync(path.dirname(managedStateFile(options.projectDir)), { recursive: true });
|
|
112
|
+
fs.writeFileSync(managedStateFile(options.projectDir), `${JSON.stringify({
|
|
113
|
+
url: apiUrl,
|
|
114
|
+
tenantId: tenant.id,
|
|
115
|
+
applicationId: application.id,
|
|
116
|
+
namespace: application.namespace || options.namespace,
|
|
117
|
+
environment: 'production',
|
|
118
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
67
119
|
return { tenant, application, apiUrl };
|
|
68
120
|
}
|
|
121
|
+
export async function finalizeManagedProvisioning(options) {
|
|
122
|
+
const publishedFile = path.join(options.projectDir, '.feltdb', 'last-published.json');
|
|
123
|
+
if (!fs.existsSync(publishedFile))
|
|
124
|
+
throw new Error('Managed provisioning did not produce .feltdb/last-published.json');
|
|
125
|
+
const published = JSON.parse(fs.readFileSync(publishedFile, 'utf8'));
|
|
126
|
+
if (!published.revisionId)
|
|
127
|
+
throw new Error('Managed provisioning did not promote an application revision');
|
|
128
|
+
const environmentFile = path.join(options.projectDir, '.env.local');
|
|
129
|
+
const environment = loadEnvironment(environmentFile);
|
|
130
|
+
const managedFile = managedStateFile(options.projectDir);
|
|
131
|
+
const managed = JSON.parse(fs.readFileSync(managedFile, 'utf8'));
|
|
132
|
+
const request = options.fetchImpl || fetch;
|
|
133
|
+
const discovery = await responseJson(await request(`${managed.url}/v1/application?application_id=${encodeURIComponent(managed.applicationId)}&environment=${encodeURIComponent(managed.environment)}`, { headers: { Authorization: `Bearer ${environment.FELTDB_TOKEN}` } }), 'Managed application data credential is not accepted by the promoted runtime');
|
|
134
|
+
if (discovery.revision_id !== published.revisionId) {
|
|
135
|
+
throw new Error('Managed runtime revision does not match the revision promoted during provisioning');
|
|
136
|
+
}
|
|
137
|
+
fs.writeFileSync(managedFile, `${JSON.stringify({ ...managed, revisionId: published.revisionId }, null, 2)}\n`, { mode: 0o600 });
|
|
138
|
+
return { revisionId: published.revisionId };
|
|
139
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// One release train keeps generated applications installable. The repository
|
|
2
2
|
// validation script checks these values against every workspace manifest.
|
|
3
|
-
export const FELTDB_PACKAGE_VERSION = '0.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.8.0';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
|
@@ -132,7 +132,10 @@ pub struct CapabilityBinding {
|
|
|
132
132
|
pub name: String,
|
|
133
133
|
#[serde(default)]
|
|
134
134
|
pub provider: Option<String>,
|
|
135
|
+
#[serde(default = "public_capability_visibility")]
|
|
136
|
+
pub visibility: String,
|
|
135
137
|
}
|
|
138
|
+
fn public_capability_visibility() -> String { "public".into() }
|
|
136
139
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
137
140
|
pub struct SecretReference {
|
|
138
141
|
pub binding: String,
|
|
@@ -269,6 +272,9 @@ pub struct ApplicationManifest {
|
|
|
269
272
|
pub agents: Vec<AgentDefinition>,
|
|
270
273
|
#[serde(default)]
|
|
271
274
|
pub capabilities: Vec<CapabilityBinding>,
|
|
275
|
+
/// Resolved, provider-neutral module semantic contracts. Secret values are forbidden.
|
|
276
|
+
#[serde(default)]
|
|
277
|
+
pub modules: Vec<serde_json::Value>,
|
|
272
278
|
#[serde(default)]
|
|
273
279
|
pub connections: Vec<ConnectionBinding>,
|
|
274
280
|
#[serde(default)]
|
|
@@ -310,6 +316,7 @@ impl ApplicationManifest {
|
|
|
310
316
|
schedules: vec![],
|
|
311
317
|
agents: vec![],
|
|
312
318
|
capabilities: vec![],
|
|
319
|
+
modules: vec![],
|
|
313
320
|
connections: vec![],
|
|
314
321
|
policies: vec![],
|
|
315
322
|
ui: UiApplicationModel::default(),
|
|
@@ -368,7 +375,14 @@ pub struct ValidationReport {
|
|
|
368
375
|
}
|
|
369
376
|
|
|
370
377
|
fn is_valid_policy_subject(subject: &str) -> bool {
|
|
371
|
-
|
|
378
|
+
subject.split('|').map(str::trim).all(|term| {
|
|
379
|
+
matches!(term, "authenticated" | "owner" | "member")
|
|
380
|
+
|| term.strip_prefix("self(").and_then(|value| value.strip_suffix(')')).is_some_and(|field| {
|
|
381
|
+
let mut characters = field.chars();
|
|
382
|
+
characters.next().is_some_and(|value| value == '_' || value.is_ascii_alphabetic())
|
|
383
|
+
&& characters.all(|value| value == '_' || value.is_ascii_alphanumeric())
|
|
384
|
+
})
|
|
385
|
+
})
|
|
372
386
|
}
|
|
373
387
|
|
|
374
388
|
fn unique(
|
|
@@ -540,6 +554,11 @@ pub fn validate_manifest(
|
|
|
540
554
|
.iter()
|
|
541
555
|
.map(|v| v.name.as_str())
|
|
542
556
|
.collect();
|
|
557
|
+
for capability in &manifest.capabilities {
|
|
558
|
+
if !matches!(capability.visibility.as_str(), "public" | "agent" | "workflow" | "internal") {
|
|
559
|
+
issues.push(issue("semantic", format!("capabilities.{}.visibility", capability.name), "visibility must be public, agent, workflow, or internal"));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
543
562
|
let workflows: BTreeSet<_> = manifest.workflows.iter().map(|v| v.name.as_str()).collect();
|
|
544
563
|
let queries: BTreeSet<_> = manifest.queries.iter().map(|v| v.name.as_str()).collect();
|
|
545
564
|
let bindings: BTreeSet<_> = manifest
|
|
@@ -718,6 +737,11 @@ pub fn validate_manifest(
|
|
|
718
737
|
}
|
|
719
738
|
}
|
|
720
739
|
}
|
|
740
|
+
for (index, module) in manifest.modules.iter().enumerate() {
|
|
741
|
+
if contains_plaintext_secret(module) || module.get("value").is_some() {
|
|
742
|
+
issues.push(issue("security", format!("modules.{index}"), "module contracts may declare secret requirements but never secret values"));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
721
745
|
for environment in &manifest.environments {
|
|
722
746
|
if environment.configuration.iter().any(|(key, value)| {
|
|
723
747
|
matches!(
|
|
@@ -853,6 +877,7 @@ fn sorted(mut manifest: ApplicationManifest) -> ApplicationManifest {
|
|
|
853
877
|
agent.capabilities.sort();
|
|
854
878
|
}
|
|
855
879
|
sort_name!(manifest.capabilities);
|
|
880
|
+
manifest.modules.sort_by(|a, b| a.get("id").and_then(Value::as_str).cmp(&b.get("id").and_then(Value::as_str)));
|
|
856
881
|
sort_name!(manifest.connections);
|
|
857
882
|
sort_name!(manifest.policies);
|
|
858
883
|
sort_name!(manifest.ui.bindings);
|
|
@@ -2468,6 +2493,21 @@ mod tests {
|
|
|
2468
2493
|
);
|
|
2469
2494
|
}
|
|
2470
2495
|
|
|
2496
|
+
#[test]
|
|
2497
|
+
fn policy_with_identity_and_membership_composition_is_valid() {
|
|
2498
|
+
let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
|
|
2499
|
+
manifest.policies.push(PolicyDefinition {
|
|
2500
|
+
name: "InvitationPolicy".into(),
|
|
2501
|
+
resource: "Invitation".into(),
|
|
2502
|
+
read: Some("self(user) | member".into()),
|
|
2503
|
+
write: Some("member".into()),
|
|
2504
|
+
capabilities: vec![],
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
let report = validate_manifest(&manifest, "tenant", "app", None);
|
|
2508
|
+
assert!(report.valid, "composed identity policy should be valid: {:?}", report.issues);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2471
2511
|
#[test]
|
|
2472
2512
|
fn policy_with_invalid_subject_rejected() {
|
|
2473
2513
|
let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
|
|
@@ -283,6 +283,10 @@ pub enum AuthorityError {
|
|
|
283
283
|
provided: FencingToken,
|
|
284
284
|
current: FencingToken,
|
|
285
285
|
},
|
|
286
|
+
QuorumUnavailable {
|
|
287
|
+
live: usize,
|
|
288
|
+
required: usize,
|
|
289
|
+
},
|
|
286
290
|
Io(String),
|
|
287
291
|
}
|
|
288
292
|
|
|
@@ -296,6 +300,12 @@ impl std::fmt::Display for AuthorityError {
|
|
|
296
300
|
Self::StaleToken { provided, current } => {
|
|
297
301
|
write!(f, "stale token {provided}, current is {current}")
|
|
298
302
|
}
|
|
303
|
+
Self::QuorumUnavailable { live, required } => {
|
|
304
|
+
write!(
|
|
305
|
+
f,
|
|
306
|
+
"authority quorum unavailable: {live} live, {required} required"
|
|
307
|
+
)
|
|
308
|
+
}
|
|
299
309
|
Self::Io(msg) => write!(f, "authority store: {msg}"),
|
|
300
310
|
}
|
|
301
311
|
}
|
|
@@ -308,6 +318,20 @@ impl AuthorityStore {
|
|
|
308
318
|
cluster_id: &str,
|
|
309
319
|
local_node_id: &str,
|
|
310
320
|
local_priority: u32,
|
|
321
|
+
) -> Result<Self, AuthorityError> {
|
|
322
|
+
Self::open_with_failure_detector(path, cluster_id, local_node_id, local_priority, 1_000, 5)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// Open with an explicitly configured failure detector. Production keeps
|
|
326
|
+
/// the conservative default above; process-level acceptance tests use a
|
|
327
|
+
/// shorter interval so failure is event-driven without multi-second sleeps.
|
|
328
|
+
pub fn open_with_failure_detector<P: AsRef<Path>>(
|
|
329
|
+
path: P,
|
|
330
|
+
cluster_id: &str,
|
|
331
|
+
local_node_id: &str,
|
|
332
|
+
local_priority: u32,
|
|
333
|
+
heartbeat_interval_ms: u64,
|
|
334
|
+
failure_threshold: u32,
|
|
311
335
|
) -> Result<Self, AuthorityError> {
|
|
312
336
|
let path = path.as_ref().to_path_buf();
|
|
313
337
|
let state = if path.exists() {
|
|
@@ -323,7 +347,7 @@ impl AuthorityStore {
|
|
|
323
347
|
Ok(Self {
|
|
324
348
|
path,
|
|
325
349
|
state,
|
|
326
|
-
heartbeats: HeartbeatState::new(
|
|
350
|
+
heartbeats: HeartbeatState::new(heartbeat_interval_ms, failure_threshold),
|
|
327
351
|
})
|
|
328
352
|
}
|
|
329
353
|
|
|
@@ -333,8 +357,8 @@ impl AuthorityStore {
|
|
|
333
357
|
std::fs::create_dir_all(parent).map_err(|e| AuthorityError::Io(e.to_string()))?;
|
|
334
358
|
}
|
|
335
359
|
let temp = self.path.with_extension("tmp");
|
|
336
|
-
let bytes =
|
|
337
|
-
|
|
360
|
+
let bytes = serde_json::to_vec_pretty(&self.state)
|
|
361
|
+
.map_err(|e| AuthorityError::Io(e.to_string()))?;
|
|
338
362
|
{
|
|
339
363
|
use std::io::Write;
|
|
340
364
|
let mut file =
|
|
@@ -416,7 +440,9 @@ impl AuthorityStore {
|
|
|
416
440
|
|
|
417
441
|
let mut record =
|
|
418
442
|
AuthorityRecord::new(self.state.local_node_id.clone(), new_token, previous);
|
|
419
|
-
record
|
|
443
|
+
record
|
|
444
|
+
.acknowledgements
|
|
445
|
+
.insert(self.state.local_node_id.clone());
|
|
420
446
|
|
|
421
447
|
self.state.current_authority = Some(record);
|
|
422
448
|
self.state.local_role = AuthorityRole::Authority;
|
|
@@ -512,6 +538,31 @@ impl AuthorityStore {
|
|
|
512
538
|
Ok(())
|
|
513
539
|
}
|
|
514
540
|
|
|
541
|
+
/// Validate an authoritative write against both its fencing term and a
|
|
542
|
+
/// live quorum. A partitioned former authority may not have observed the
|
|
543
|
+
/// replacement's newer token yet, so fencing alone is insufficient until
|
|
544
|
+
/// healing; expiry of its quorum evidence closes that split-brain window.
|
|
545
|
+
pub fn validate_quorum_write(
|
|
546
|
+
&self,
|
|
547
|
+
token: FencingToken,
|
|
548
|
+
required_quorum: usize,
|
|
549
|
+
) -> Result<(), AuthorityError> {
|
|
550
|
+
self.validate_write(Some(token))?;
|
|
551
|
+
let live = 1 + self
|
|
552
|
+
.heartbeats
|
|
553
|
+
.last_seen
|
|
554
|
+
.keys()
|
|
555
|
+
.filter(|node_id| !self.heartbeats.is_failed(node_id))
|
|
556
|
+
.count();
|
|
557
|
+
if live < required_quorum {
|
|
558
|
+
return Err(AuthorityError::QuorumUnavailable {
|
|
559
|
+
live,
|
|
560
|
+
required: required_quorum,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
Ok(())
|
|
564
|
+
}
|
|
565
|
+
|
|
515
566
|
/// Step down from authority role (voluntary).
|
|
516
567
|
pub fn step_down(&mut self) -> Result<(), AuthorityError> {
|
|
517
568
|
if self.state.local_role == AuthorityRole::Authority {
|
|
@@ -746,13 +797,40 @@ mod tests {
|
|
|
746
797
|
assert!(heartbeats.is_failed("A"));
|
|
747
798
|
}
|
|
748
799
|
|
|
800
|
+
#[test]
|
|
801
|
+
fn partitioned_authority_loses_write_quorum() {
|
|
802
|
+
let dir = tempfile::tempdir().unwrap();
|
|
803
|
+
let mut store = AuthorityStore::open_with_failure_detector(
|
|
804
|
+
dir.path().join("authority.json"),
|
|
805
|
+
"test-cluster",
|
|
806
|
+
"A",
|
|
807
|
+
10,
|
|
808
|
+
1,
|
|
809
|
+
1,
|
|
810
|
+
)
|
|
811
|
+
.expect("open authority store");
|
|
812
|
+
let token = match store.claim_authority().expect("claim") {
|
|
813
|
+
ClaimResult::Granted { token } => token,
|
|
814
|
+
_ => panic!("authority claim was not granted"),
|
|
815
|
+
};
|
|
816
|
+
store.heartbeat("B");
|
|
817
|
+
assert!(store.validate_quorum_write(token, 2).is_ok());
|
|
818
|
+
store.heartbeats.last_seen.insert("B".to_string(), 0);
|
|
819
|
+
assert!(matches!(
|
|
820
|
+
store.validate_quorum_write(token, 2),
|
|
821
|
+
Err(AuthorityError::QuorumUnavailable {
|
|
822
|
+
live: 1,
|
|
823
|
+
required: 2
|
|
824
|
+
})
|
|
825
|
+
));
|
|
826
|
+
}
|
|
827
|
+
|
|
749
828
|
#[test]
|
|
750
829
|
fn recovery_from_fenced() {
|
|
751
830
|
let dir = tempfile::tempdir().unwrap();
|
|
752
831
|
let path = dir.path().join("authority.json");
|
|
753
832
|
|
|
754
|
-
let mut store =
|
|
755
|
-
AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
|
|
833
|
+
let mut store = AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
|
|
756
834
|
store.claim_authority().expect("claim");
|
|
757
835
|
|
|
758
836
|
// Get fenced
|