Repository navigation
Expand file tree
/
Copy pathmod.rs
More file actions
310 lines (281 loc) · 10.8 KB
/
Copy pathmod.rs
File metadata and controls
310 lines (281 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
//! Package operations used by the runner.
//!
//! Implementations translate this small capability surface to either the real
//! Dappmanager MCP tools or a deterministic local fake.
use async_trait::async_trait;
use thiserror::Error;
use crate::model::{
DnpName, PackageDetails, PackageLogs, PackageRef, PackageSummary, PreviewSummary,
};
pub mod dappmanager;
pub mod fake;
pub mod unavailable;
pub use dappmanager::DappmanagerPackageManager;
pub use fake::FakePackageManager;
pub use unavailable::UnavailablePackageManager;
/// MCP tools the real Dappmanager implementation needs before accepting runs.
pub const REQUIRED_MCP_TOOLS: [&str; 7] = [
"dappnode_list_packages",
"dappnode_get_package_details",
"dappnode_get_package_logs",
"dappnode_fetch_install_preview",
"dappnode_install_package",
"dappnode_update_package",
"dappnode_remove_package",
];
/// Required tools that mutate packages or Docker state.
pub const MUTATING_MCP_TOOLS: [&str; 3] = [
"dappnode_install_package",
"dappnode_update_package",
"dappnode_remove_package",
];
/// Result of checking the Dappmanager MCP tool inventory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolAvailability {
pub available: Vec<String>,
pub missing: Vec<String>,
}
impl ToolAvailability {
/// True when every required tool is available.
pub fn ready(&self) -> bool {
self.missing.is_empty()
}
/// Human-readable readiness message suitable for `/readyz`.
pub fn message(&self) -> String {
if self.ready() {
return "all required Dappmanager MCP tools are available".to_owned();
}
let mutating_missing = self
.missing
.iter()
.any(|tool| MUTATING_MCP_TOOLS.contains(&tool.as_str()));
if mutating_missing {
format!(
"required MCP tools are missing: {}; enable external MCP mutating tools in Dappmanager System > Advanced and use a valid MCP bearer token",
self.missing.join(", ")
)
} else {
format!(
"required MCP tools are missing: {}",
self.missing.join(", ")
)
}
}
}
/// Error raised while talking to or interpreting the package manager.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum PackageManagerError {
#[error("package manager configuration is invalid: {0}")]
Configuration(String),
#[error("MCP transport failure: {0}")]
Transport(String),
#[error("MCP tool '{tool}' timed out")]
Timeout { tool: String },
#[error("MCP tool '{tool}' failed: {message}")]
Tool { tool: String, message: String },
#[error("MCP tool '{tool}' returned invalid data: {message}")]
InvalidResponse { tool: String, message: String },
#[error("package requires setup values that the proof of concept cannot supply")]
RequiredSetup,
#[error("package was not found")]
NotFound,
}
impl PackageManagerError {
/// A deterministic registry-name resolution failure, rather than a network
/// outage or a generic contract revert. Inspect the full tool error before
/// bounding it: provider wrappers may prepend RPC failures.
pub fn is_unresolvable_registry_name(&self) -> bool {
let Self::Tool { tool, message } = self else {
return false;
};
if tool != "dappnode_fetch_install_preview" {
return false;
}
let lower = message.to_ascii_lowercase();
lower.contains("unreachablename(")
|| lower.contains("resolvernotfound(")
// ENS Universal Resolver's UnreachableName(bytes) and
// ResolverNotFound(bytes) error selectors.
|| lower.contains("0x5fe9a5df")
|| lower.contains("0x77209fe8")
|| (lower.contains("could not resolve name") && lower.contains(": norepo"))
}
/// Whether repeating a package mutation may recover from this failure.
pub fn is_transient_mutation_failure(&self) -> bool {
match self {
Self::Timeout { .. } => true,
Self::Transport(message) => retryable_transport_message(message),
Self::Tool { message, .. } => retryable_tool_message(message),
Self::Configuration(_)
| Self::InvalidResponse { .. }
| Self::RequiredSetup
| Self::NotFound => false,
}
}
/// Whether DAppManager rejected a package because its signature was not safe.
pub fn is_signature_rejection(&self) -> bool {
let message = self.to_string().to_ascii_lowercase();
let mentions_signature = message.contains("signature")
|| message.contains("signed restriction")
|| message.contains("not signed")
|| message.contains("unsigned");
let indicates_rejection = message.contains("invalid")
|| message.contains("bad")
|| message.contains("unsafe")
|| message.contains("not signed")
|| message.contains("unsigned")
|| message.contains("restriction")
|| message.contains("reject")
|| message.contains("bypass_signed_restriction");
mentions_signature && indicates_rejection
}
}
pub(crate) fn retryable_transport_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
!lower.contains("401")
&& !lower.contains("403")
&& !lower.contains("unauthorized")
&& !lower.contains("forbidden")
&& !lower.contains("not_logged_in")
}
pub(crate) fn retryable_tool_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
[
"can't download",
"cannot download",
"could not get block",
"failed to fetch",
"connection reset",
"econnreset",
"socket hang up",
"temporarily unavailable",
"timed out",
"timeout",
"terminated",
]
.iter()
.any(|signature| lower.contains(signature))
}
/// Capability surface required to install, observe, and clean up a package.
#[async_trait]
pub trait PackageManager: Send + Sync {
/// Checks whether the implementation has the required mutating and read-only tools.
async fn verify_tools(&self) -> Result<ToolAvailability, PackageManagerError>;
/// Lists currently installed packages.
async fn list_packages(&self) -> Result<Vec<PackageSummary>, PackageManagerError>;
/// Returns package metadata and container state.
async fn get_package_details(
&self,
dnp_name: &DnpName,
) -> Result<PackageDetails, PackageManagerError>;
/// Returns recent package logs, usually bounded by `tail` lines per container.
async fn get_package_logs(
&self,
dnp_name: &DnpName,
tail: usize,
) -> Result<PackageLogs, PackageManagerError>;
/// Returns Dappmanager's preview for installing a package version.
async fn preview_install(
&self,
dnp_name: &DnpName,
version: Option<&PackageRef>,
) -> Result<PreviewSummary, PackageManagerError>;
/// Installs the baseline version, or the default registry version when `version` is absent.
async fn install_package(
&self,
dnp_name: &DnpName,
version: Option<&PackageRef>,
) -> Result<(), PackageManagerError>;
/// Installs a baseline after its signature rejection has been reported.
async fn install_package_bypassing_signature(
&self,
dnp_name: &DnpName,
version: Option<&PackageRef>,
) -> Result<(), PackageManagerError> {
self.install_package(dnp_name, version).await
}
/// Updates an installed package to the candidate reference.
async fn update_package(
&self,
dnp_name: &DnpName,
version: &PackageRef,
) -> Result<(), PackageManagerError>;
/// Updates to a baseline after its signature rejection has been reported.
async fn update_package_bypassing_signature(
&self,
dnp_name: &DnpName,
version: &PackageRef,
) -> Result<(), PackageManagerError> {
self.update_package(dnp_name, version).await
}
/// Removes the package after a run.
async fn remove_package(
&self,
dnp_name: &DnpName,
delete_volumes: bool,
) -> Result<(), PackageManagerError>;
}
#[cfg(test)]
mod tests {
use super::PackageManagerError;
#[test]
fn identifies_only_deterministic_registry_resolution_failures() {
let preview_error = |message: &str| PackageManagerError::Tool {
tool: "dappnode_fetch_install_preview".to_owned(),
message: message.to_owned(),
};
for message in [
"execution reverted: UnreachableName(bytes)",
"execution reverted: ResolverNotFound(bytes)",
"MultiUrlJsonRpcProvider: all RPC endpoints failed. local: TypeError: fetch failed; fallback: execution reverted (data=\"0x5fe9a5df00000000\")",
"execution reverted (data=\"0x77209fe800000000\")",
"Could not resolve name pi-hole.dnp.dappnode.eth: NOREPO",
] {
assert!(
preview_error(message).is_unresolvable_registry_name(),
"{message}"
);
}
for message in [
"all RPC endpoints failed: fetch failed",
"execution reverted (unknown custom error)",
"registry version lookup reverted",
"IPFS block not found",
"401 unauthorized",
] {
assert!(
!preview_error(message).is_unresolvable_registry_name(),
"{message}"
);
}
assert!(
!PackageManagerError::Transport("UnreachableName(bytes)".to_owned())
.is_unresolvable_registry_name()
);
assert!(
!PackageManagerError::Tool {
tool: "dappnode_update_package".to_owned(),
message: "UnreachableName(bytes)".to_owned(),
}
.is_unresolvable_registry_name()
);
}
#[test]
fn identifies_signature_rejections_without_matching_unrelated_errors() {
let invalid_signature = PackageManagerError::Tool {
tool: "dappnode_install_package".to_owned(),
message: "unsafe origin, bad signature".to_owned(),
};
let unsigned = PackageManagerError::Tool {
tool: "dappnode_install_package".to_owned(),
message: "package is not signed; set BYPASS_SIGNED_RESTRICTION".to_owned(),
};
let invalid_manifest = PackageManagerError::Tool {
tool: "dappnode_install_package".to_owned(),
message: "package manifest is invalid".to_owned(),
};
assert!(invalid_signature.is_signature_rejection());
assert!(unsigned.is_signature_rejection());
assert!(!invalid_manifest.is_signature_rejection());
}
}