feat(install): route any manifest-known app through the orchestrator

package.install routed to the manifest-driven orchestrator only for a
hardcoded per-app allowlist; every other app fell through to the legacy
flow, which ignores manifests entirely and creates a bare container with
no ports or volumes (strfry crash-looped this way on .228, 2026-07-09).

New ContainerOrchestrator::knows_app(app_id) (default false; prod checks
its loaded-manifest map, disk + signed-catalog overlay). The install gate
is now allowlist OR knows_app — no per-app Rust for manifest-driven apps,
matching the packaging invariant. Apps without a manifest keep the legacy
flow including the container-exists adopt block; the unknown-app_id →
legacy fallback inside the orchestrator branch is unchanged.

Tests: knows_app_reflects_loaded_manifests + install suite 37/37.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-09 14:46:50 -04:00
parent 707c912606
commit 55c20e0d6e
3 changed files with 49 additions and 2 deletions

View File

@ -330,8 +330,20 @@ impl RpcHandler {
// mode).
// The adoption block is being phased out as apps move to the
// orchestrator path. Non-orchestrator apps still hit it.
let orchestrator_managed =
should_try_orchestrator_install(package_id, self.orchestrator.is_some());
let orchestrator_managed = match self.orchestrator.as_ref() {
// Migration allowlist OR any app whose manifest the orchestrator
// knows (disk or signed-catalog overlay). A manifest-driven app
// must never fall through to the legacy flow — it ignores the
// manifest entirely and creates a bare container with no ports or
// volumes (strfry, 2026-07-09).
Some(orch) => {
should_try_orchestrator_install(package_id, true)
|| orch
.knows_app(orchestrator_install_app_id(package_id))
.await
}
None => false,
};
// Check if container already exists (legacy adoption — non-orchestrator
// apps only).

View File

@ -3610,6 +3610,10 @@ enum HookOutcome {
#[async_trait]
impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn knows_app(&self, app_id: &str) -> bool {
self.state.read().await.manifests.contains_key(app_id)
}
async fn install(&self, app_id: &str) -> Result<String> {
{
let mut state = self.state.write().await;
@ -4679,6 +4683,27 @@ app:
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
}
#[tokio::test]
async fn knows_app_reflects_loaded_manifests() {
// The RPC install gate routes any app with a known manifest through
// the orchestrator (no per-app allowlist). knows_app must be true for
// a loaded manifest and false otherwise — a false positive would send
// a manifest-less app into `loaded()`'s unknown-app_id error path,
// skipping the legacy adopt block; a false negative sends a
// manifest-driven app to the legacy flow, which creates a bare
// container (strfry, 2026-07-09).
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
assert!(!orch.knows_app("strfry").await);
orch.insert_manifest_for_test(
pull_manifest("strfry", "docker.io/dockurr/strfry:1.0.4"),
PathBuf::from("/tmp/fixtures/strfry"),
)
.await;
assert!(orch.knows_app("strfry").await);
assert!(!orch.knows_app("no-such-app").await);
}
#[tokio::test]
async fn mempool_core_rpc_host_follows_bitcoin_node() {
// B12: mempool's CORE_RPC_HOST must resolve to whichever Bitcoin node

View File

@ -25,6 +25,16 @@ pub trait ContainerOrchestrator: Send + Sync {
/// to a manifest the orchestrator already knows about.
async fn install(&self, app_id: &str) -> Result<String>;
/// True when this orchestrator holds a manifest for `app_id` (disk or
/// signed-catalog overlay) — i.e. `install(app_id)` would not fail with
/// "unknown app_id". Lets the RPC layer route any manifest-driven app
/// through the orchestrator without a per-app allowlist. Defaults to
/// `false` so orchestrators without a manifest registry keep routing
/// through the legacy install flow.
async fn knows_app(&self, _app_id: &str) -> bool {
false
}
/// Start an already-created container.
async fn start(&self, app_id: &str) -> Result<()>;