Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0f924d7
provision --local implementation: tracking branch
aram356 Jun 29, 2026
1ffe5a9
Add ProvisionMode + ProvisionArgs.local for provision --local
aram356 Jun 29, 2026
feb607e
Add neutral ProvisionOutcome + AdapterDeployedState types + crate-roo…
aram356 Jun 29, 2026
5896a8e
Thread ProvisionMode + ProvisionOutcome through Adapter::provision
aram356 Jun 30, 2026
63f9a4d
Add Adapter::provision_typed trait method with default no-op
aram356 Jun 30, 2026
5121603
Extract run_typed_preflight; route validate/push/diff through it
aram356 Jun 30, 2026
a8e54e1
Task 5 fix: promote resolve_app_config_path_primitive to pub(crate) p…
aram356 Jun 30, 2026
d184c8f
Add path_safety module with containment helper
aram356 Jun 30, 2026
a347422
Wire path_safety into config push --local
aram356 Jul 1, 2026
d8d6622
Wire path_safety + ProvisionMode into run_provision
aram356 Jul 1, 2026
7074ac7
Add Adapter::synthesise_baseline_manifest + CLI bootstrap before vali…
aram356 Jul 1, 2026
a0bec7a
Add copy_tree helper; promote tempfile + toml_edit to CLI runtime deps
aram356 Jul 1, 2026
3fa8c22
Merge branch 'main' into feature/provision-local-impl
aram356 Jul 1, 2026
ba3f891
Move config push --local containment guard above run_shared_checks
aram356 Jul 1, 2026
619541a
Merge remote-tracking branch 'origin/feature/provision-local-impl' in…
aram356 Jul 1, 2026
1617bb8
Make provision_local accept-tests actually exercise their code paths
aram356 Jul 2, 2026
12d7129
Add run_with_staging tempdir helper for dry-run staging
aram356 Jul 2, 2026
42c198e
Wire mode x dry-run dispatch matrix into run_provision
aram356 Jul 2, 2026
cd87c24
Add dry-run allow-list diff + would-write status rewriting
aram356 Jul 2, 2026
66ab458
Add provision_local_dry_run worktree-clean + no-tempdir-leak test (ig…
aram356 Jul 2, 2026
2b3e700
Add ManifestAdapterDeployed struct + deployed field on ManifestAdapter
aram356 Jul 2, 2026
5531f72
Fix local dry-run to dispatch adapters with dry_run = false
aram356 Jul 2, 2026
6033e72
Add adapter-owned deployed_fields + Manifest-level cross-check
aram356 Jul 2, 2026
af4baa9
Task 15 followup: refresh #[expect] reasons + document case-handling
aram356 Jul 2, 2026
b3466c1
Add toml_edit-based [adapters.<name>.deployed] writeback
aram356 Jul 2, 2026
277f614
Cloudflare: cloud provision returns created namespace ids via deployed
aram356 Jul 2, 2026
d7d6b1a
Add edgezero_adapter::env_file::append_lines_dedup (used by Section 5)
aram356 Jul 2, 2026
9c21d15
Wire deployed_state_for translator + validate_deployed_field_ownershi…
aram356 Jul 2, 2026
fac82ab
Clean up misleading adapter-named tests in provision.rs
aram356 Jul 2, 2026
1e1e206
Fix three review findings: dry-run leak, baseline containment, deploy…
aram356 Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions crates/edgezero-adapter-axum/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{
find_manifest_upwards, find_workspace_root, path_distance, read_package_name,
};
use edgezero_adapter::registry::{
register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry,
ResolvedStoreId,
register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext,
ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId,
};
use edgezero_adapter::scaffold::{
register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates,
Expand Down Expand Up @@ -162,8 +162,14 @@ impl Adapter for AxumCliAdapter {
_adapter_manifest_path: Option<&str>,
_component_selector: Option<&str>,
stores: &ProvisionStores<'_>,
_deployed: Option<&AdapterDeployedState>,
mode: ProvisionMode,
_dry_run: bool,
) -> Result<Vec<String>, String> {
) -> Result<ProvisionOutcome, String> {
match mode {
ProvisionMode::Cloud => {}
ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()),
}
//: axum has no remote resources. Print one note per
// declared store id so the operator sees the CLI heard
// them — same shape `dry_run` would have, since there is
Expand Down Expand Up @@ -200,7 +206,10 @@ impl Adapter for AxumCliAdapter {
if out.is_empty() {
out.push("axum has no declared stores to provision".to_owned());
}
Ok(out)
Ok(ProvisionOutcome {
status_lines: out,
deployed: None,
})
}

fn push_config_entries(
Expand Down
241 changes: 215 additions & 26 deletions crates/edgezero-adapter-cloudflare/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::io::ErrorKind;
Expand All @@ -10,8 +10,8 @@ use edgezero_adapter::cli_support::{
find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli,
};
use edgezero_adapter::registry::{
register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry,
ResolvedStoreId,
register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext,
ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId,
};
use edgezero_adapter::scaffold::{
register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates,
Expand Down Expand Up @@ -136,6 +136,10 @@ struct CloudflareCliAdapter;
reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)."
)]
impl Adapter for CloudflareCliAdapter {
fn deployed_fields(&self) -> &'static [&'static str] {
&["kv_namespaces", "preview_kv_namespaces"]
}

fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> {
match action {
// `wrangler` is the native sign-in surface for Cloudflare
Expand Down Expand Up @@ -190,8 +194,14 @@ impl Adapter for CloudflareCliAdapter {
adapter_manifest_path: Option<&str>,
_component_selector: Option<&str>,
stores: &ProvisionStores<'_>,
_deployed: Option<&AdapterDeployedState>,
mode: ProvisionMode,
dry_run: bool,
) -> Result<Vec<String>, String> {
) -> Result<ProvisionOutcome, String> {
match mode {
ProvisionMode::Cloud => {}
ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()),
}
//: KV ids and config ids both back to Cloudflare KV
// namespaces. Secrets are runtime-managed via
// `wrangler secret put` — provision is a no-op for them.
Expand All @@ -204,6 +214,16 @@ impl Adapter for CloudflareCliAdapter {
let wrangler_path = manifest_root.join(rel);

let mut out = Vec::new();
// Track logical -> namespace_id for freshly-created namespaces
// so the CLI's writeback can persist them under
// `[adapters.cloudflare.deployed].kv_namespaces.<logical>`.
// Keyed by LOGICAL id so teammates' env overlays (which
// change the platform binding name) still resolve the same
// mapping on their side. Only populated in the non-dry-run
// create branch below -- dry-runs and idempotency skips
// contribute nothing (no real wrangler invocation, no id to
// record).
let mut created_kv_ns: BTreeMap<String, String> = BTreeMap::new();
for store in stores.kv.iter().chain(stores.config.iter()) {
let logical = &store.logical;
// The Cloudflare KV binding name is what the runtime
Expand Down Expand Up @@ -273,6 +293,13 @@ impl Adapter for CloudflareCliAdapter {
"created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}",
wrangler_path.display()
));
// Record under the LOGICAL id, not the platform binding.
// Teammates' `provision --local` re-resolves logical ->
// platform via THEIR env overlay and reads the namespace
// id back via the same logical key -- keying by
// `binding` (platform) would break that lookup when
// the overlays diverge.
created_kv_ns.insert(logical.clone(), namespace_id);
}
for store in stores.secrets {
let logical = &store.logical;
Expand All @@ -284,7 +311,27 @@ impl Adapter for CloudflareCliAdapter {
if out.is_empty() {
out.push("cloudflare has no declared stores to provision".to_owned());
}
Ok(out)
// dry_run branch above `continue`s BEFORE reaching
// `create_kv_namespace`, so `created_kv_ns` stays empty for
// dry-runs -- `deployed` collapses to `None` and the CLI
// writeback is a no-op. An idempotent skip (binding already
// present with a real id) similarly doesn't repopulate the
// map, since the existing id is already recorded in the
// operator's `[adapters.cloudflare.deployed]` block from a
// prior run.
let deployed = if created_kv_ns.is_empty() {
None
} else {
let mut state = AdapterDeployedState::default();
state
.sub_tables
.insert("kv_namespaces".to_owned(), created_kv_ns);
Some(state)
};
Ok(ProvisionOutcome {
status_lines: out,
deployed,
})
}

fn push_config_entries(
Expand Down Expand Up @@ -1572,14 +1619,22 @@ id = "00112233445566778899aabbccddeeff"
secrets: &secret_ids,
};
let out = CloudflareCliAdapter
.provision(dir.path(), Some("wrangler.toml"), None, &stores, true)
.provision(
dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
true,
)
.expect("dry-run succeeds");
// 2 KV + 1 config + 1 secret = 4 status lines.
assert_eq!(out.len(), 4);
assert!(out[0].contains("would run `wrangler kv namespace create sessions`"));
assert!(out[1].contains("would run `wrangler kv namespace create cache`"));
assert!(out[2].contains("would run `wrangler kv namespace create app_config`"));
assert!(out[3].contains("runtime-managed via `wrangler secret put`"));
assert_eq!(out.status_lines.len(), 4);
assert!(out.status_lines[0].contains("would run `wrangler kv namespace create sessions`"));
assert!(out.status_lines[1].contains("would run `wrangler kv namespace create cache`"));
assert!(out.status_lines[2].contains("would run `wrangler kv namespace create app_config`"));
assert!(out.status_lines[3].contains("runtime-managed via `wrangler secret put`"));
// Manifest untouched.
let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read");
assert_eq!(after, "name = \"demo\"\n", "dry-run mutated wrangler.toml");
Expand All @@ -1603,19 +1658,27 @@ id = "00112233445566778899aabbccddeeff"
secrets: &[],
};
let out = CloudflareCliAdapter
.provision(dir.path(), Some("wrangler.toml"), None, &stores, true)
.provision(
dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
true,
)
.expect("dry-run succeeds");
assert_eq!(out.len(), 1);
assert_eq!(out.status_lines.len(), 1);
assert!(
out[0].contains("wrangler kv namespace create prod_config"),
out.status_lines[0].contains("wrangler kv namespace create prod_config"),
"dry-run uses platform name in the `wrangler` invocation: {out:?}"
);
assert!(
out[0].contains("binding = \"prod_config\""),
out.status_lines[0].contains("binding = \"prod_config\""),
"dry-run writes platform name as the binding: {out:?}"
);
assert!(
out[0].contains("logical id `app_config`"),
out.status_lines[0].contains("logical id `app_config`"),
"logical id is preserved for operator wording: {out:?}"
);
}
Expand All @@ -1630,7 +1693,15 @@ id = "00112233445566778899aabbccddeeff"
secrets: &[],
};
let err = CloudflareCliAdapter
.provision(dir.path(), None, None, &stores, true)
.provision(
dir.path(),
None,
None,
&stores,
None,
ProvisionMode::Cloud,
true,
)
.expect_err("missing adapter manifest path must error");
assert!(
err.contains("wrangler.toml"),
Expand All @@ -1653,12 +1724,20 @@ id = "00112233445566778899aabbccddeeff"
secrets: &[],
};
let out = CloudflareCliAdapter
.provision(dir.path(), Some("wrangler.toml"), None, &stores, true)
.provision(
dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
true,
)
.expect("dry-run succeeds");
assert_eq!(out.len(), 1);
assert_eq!(out.status_lines.len(), 1);
assert!(
out[0].contains("already provisioned")
&& out[0].contains("00112233445566778899aabbccddeeff"),
out.status_lines[0].contains("already provisioned")
&& out.status_lines[0].contains("00112233445566778899aabbccddeeff"),
"skip line names the existing id: {out:?}"
);
let after = fs::read_to_string(&path).expect("read");
Expand Down Expand Up @@ -1686,11 +1765,19 @@ id = "00112233445566778899aabbccddeeff"
secrets: &[],
};
let out = CloudflareCliAdapter
.provision(dir.path(), Some("wrangler.toml"), None, &stores, true)
.provision(
dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
true,
)
.expect("dry-run succeeds");
assert_eq!(out.len(), 1);
assert_eq!(out.status_lines.len(), 1);
assert!(
out[0].contains("would run `wrangler kv namespace create sessions`"),
out.status_lines[0].contains("would run `wrangler kv namespace create sessions`"),
"placeholder id is treated as unprovisioned: {out:?}"
);
}
Expand All @@ -1705,9 +1792,111 @@ id = "00112233445566778899aabbccddeeff"
secrets: &[],
};
let out = CloudflareCliAdapter
.provision(dir.path(), Some("wrangler.toml"), None, &stores, false)
.provision(
dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
false,
)
.expect("no-store provision is fine");
assert_eq!(out, vec!["cloudflare has no declared stores to provision"]);
assert_eq!(
out.status_lines,
vec!["cloudflare has no declared stores to provision"]
);
// No wrangler was invoked (no stores) => no id to record.
assert!(
out.deployed.is_none(),
"no-store provision has nothing to write back: {:?}",
out.deployed
);
}

#[cfg(unix)]
#[test]
fn cloudflare_cloud_provision_returns_created_namespace_ids() {
// Non-dry-run Cloud provision must populate
// `deployed.sub_tables["kv_namespaces"]` keyed by LOGICAL id
// (not the platform binding name). Task 16's CLI writeback
// then lands them under `[adapters.cloudflare.deployed]`.
//
// Uses the same wrangler-fake shim pattern as the
// read_config_entry tests: a shell script on PATH prints the
// Wrangler-3 `[[kv_namespaces]] / id = "..."` block that
// `extract_namespace_id` parses.
let _lock = path_mutation_guard().lock().expect("guard");
let project_dir = tempdir().expect("tempdir");
write_wrangler(project_dir.path(), "name = \"demo\"\n");
let stdout = "[[kv_namespaces]]\nbinding = \"ignored-by-parser\"\nid = \"00112233445566778899aabbccddeeff\"\n";
let fake = fake_wrangler_returning(stdout, "", 0);
let _path = PathPrepend::new(fake.path());

let kv_ids: Vec<ResolvedStoreId> = ResolvedStoreId::from_logicals(&[TEST_KV_ID]);
let stores = ProvisionStores {
config: &[],
kv: &kv_ids,
secrets: &[],
};
let out = CloudflareCliAdapter
.provision(
project_dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
false,
)
.expect("cloud provision succeeds against fake wrangler");
let deployed = out
.deployed
.expect("cloud provision with creates populates deployed");
let kv = deployed
.sub_tables
.get("kv_namespaces")
.expect("deployed carries kv_namespaces sub-table");
// Key MUST be the LOGICAL id -- teammates' env overlays
// change the platform binding, but the logical id is
// env-overlay-independent.
assert_eq!(
kv.get(TEST_KV_ID).map(String::as_str),
Some("00112233445566778899aabbccddeeff"),
"kv_namespaces keyed by logical id `{TEST_KV_ID}`: {kv:?}"
);
}

#[test]
fn cloudflare_cloud_provision_dry_run_returns_none_deployed() {
// Cloud dry-run means no real `wrangler kv namespace create`
// invocation happened -- no real id to record. `deployed`
// must be `None` so the CLI writeback is a no-op.
let dir = tempdir().expect("tempdir");
write_wrangler(dir.path(), "name = \"demo\"\n");
let kv_ids: Vec<ResolvedStoreId> = ResolvedStoreId::from_logicals(&[TEST_KV_ID]);
let config_ids: Vec<ResolvedStoreId> = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]);
let stores = ProvisionStores {
config: &config_ids,
kv: &kv_ids,
secrets: &[],
};
let out = CloudflareCliAdapter
.provision(
dir.path(),
Some("wrangler.toml"),
None,
&stores,
None,
ProvisionMode::Cloud,
true,
)
.expect("dry-run succeeds");
assert!(
out.deployed.is_none(),
"dry-run must not populate deployed (no wrangler ran): {:?}",
out.deployed
);
}

// ---------- find_namespace_id ----------
Expand Down
Loading