From b87af3781cea7f8b27baba416a4796eb0e00d7ae Mon Sep 17 00:00:00 2001 From: yuanzui-cf Date: Sat, 12 Sep 2026 17:00:45 +0800 Subject: [PATCH 1/6] refactor(rust): Remove obsolete code and DNS writes Refs #196 --- Cargo.lock | 103 ------- Cargo.toml | 1 - apps/control-api/Cargo.toml | 1 - apps/control-api/src/domain/deployments.rs | 19 -- apps/control-api/src/domain/projects.rs | 9 - apps/control-api/src/domain/teams.rs | 1 - apps/control-api/src/features/actions.rs | 1 - .../src/features/api/v1/projects.rs | 3 - apps/control-api/src/features/mod.rs | 1 - .../control-api/src/infra/database/migrate.rs | 81 +++--- .../src/infra/host_provision/cloudflare.rs | 113 +------- .../src/infra/host_provision/dnspod.rs | 105 +------ .../src/infra/host_provision/mod.rs | 94 ------- .../src/infra/host_provision/route53.rs | 260 +----------------- apps/control-api/src/infra/quota/mod.rs | 23 -- apps/node/src/build/logs.rs | 10 - apps/node/src/build/mod.rs | 6 +- apps/node/src/client/mod.rs | 26 +- apps/node/src/runtime/mod.rs | 31 ++- apps/node/src/runtime/socket.rs | 29 +- apps/node/src/serve/release_smoke.rs | 6 +- apps/node/src/serve/ssr.rs | 4 +- crates/archive/src/lib.rs | 5 - crates/assets/Cargo.toml | 1 - docs/regional-ingress.md | 19 +- docs/self-hosting.md | 5 +- docs/todo.md | 12 + 27 files changed, 117 insertions(+), 852 deletions(-) delete mode 100644 apps/control-api/src/features/actions.rs diff --git a/Cargo.lock b/Cargo.lock index c10655c..3f38ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,21 +48,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -410,18 +395,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-executor" version = "1.14.0" @@ -873,27 +846,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -1062,26 +1014,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", - "zstd", - "zstd-safe", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1792,7 +1724,6 @@ dependencies = [ name = "grass-assets" version = "0.1.0" dependencies = [ - "mime_guess", "rust-embed", ] @@ -1869,7 +1800,6 @@ dependencies = [ "toml 0.8.23", "totp-rs", "tower", - "tower-http", "tracing", "tracing-subscriber", "url", @@ -5313,20 +5243,15 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "async-compression", "bitflags", "bytes", - "futures-core", "futures-util", "http", "http-body", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", "url", ] @@ -6480,31 +6405,3 @@ dependencies = [ "log", "simd-adler32", ] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index 7650f76..10b5de1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "signal", tokio-util = { version = "0.7", features = ["io"] } totp-rs = { version = "5", features = ["gen_secret", "otpauth"] } toml = { version = "0.8" } -tower-http = { version = "0.6", features = ["cors", "compression-full", "trace"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt", "json"] } url = "2.5.4" diff --git a/apps/control-api/Cargo.toml b/apps/control-api/Cargo.toml index cd53ca5..f9db5e8 100644 --- a/apps/control-api/Cargo.toml +++ b/apps/control-api/Cargo.toml @@ -50,7 +50,6 @@ tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "sign tokio-util.workspace = true tokio-tungstenite.workspace = true totp-rs.workspace = true -tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true url.workspace = true diff --git a/apps/control-api/src/domain/deployments.rs b/apps/control-api/src/domain/deployments.rs index 27c818d..ef5c680 100644 --- a/apps/control-api/src/domain/deployments.rs +++ b/apps/control-api/src/domain/deployments.rs @@ -76,7 +76,6 @@ pub enum DeploymentStateError { #[error("invalid release status transition from {from} to {to}")] InvalidReleaseTransition { from: String, to: String }, #[error("invalid serve status transition from {from} to {to}")] - #[allow(dead_code)] // Constructed by the P3.2 Serve status endpoint. InvalidServeTransition { from: String, to: String }, #[error("only ready deployments can enter the release flow")] BuildNotReady, @@ -401,7 +400,6 @@ pub async fn list_for_project( .map_err(Into::into) } -#[allow(dead_code)] // Wired by the serve resolve API in Milestone 6. pub async fn find_active( db: &C, project_id: Uuid, @@ -417,7 +415,6 @@ pub async fn find_active( .map_err(Into::into) } -#[allow(dead_code)] // Wired by the serve resolve API in Milestone 6. pub async fn find_by_preview_host( db: &C, host: &str, @@ -453,14 +450,12 @@ pub struct BuildTransition { pub build_node_id: Option, } -#[allow(dead_code)] // Constructed by the P3.2 Serve status endpoint. pub struct ServeTransition { pub to: DeploymentServeStatus, pub failure_code: Option, pub failure_message: Option, } -#[allow(dead_code)] // Called by the P3.2 Serve status endpoint. pub async fn transition_serve( db: &C, deployment: deployment::Model, @@ -579,7 +574,6 @@ pub async fn transition_build( /// Updates only the build stage without changing status; used for progress /// reporting between status transitions. -#[allow(dead_code)] // Wired by the Node stage API in Milestone 6. pub async fn update_stage( db: &C, deployment: deployment::Model, @@ -716,19 +710,6 @@ pub fn release_reason_value(reason: &ReleaseReason) -> &'static str { } } -#[allow(dead_code)] // Wired by the deployment timeline in Milestone 11. -pub async fn list_releases_for_project( - db: &C, - project_id: Uuid, -) -> anyhow::Result> { - release::Entity::find() - .filter(release::Column::ProjectId.eq(project_id)) - .order_by_desc(release::Column::CreatedAt) - .all(db) - .await - .map_err(Into::into) -} - /// Whether this deployment was ever active, which is what makes it a valid /// rollback target. pub async fn was_active(db: &C, deployment_id: Uuid) -> anyhow::Result { diff --git a/apps/control-api/src/domain/projects.rs b/apps/control-api/src/domain/projects.rs index 27a2d89..95db359 100644 --- a/apps/control-api/src/domain/projects.rs +++ b/apps/control-api/src/domain/projects.rs @@ -148,14 +148,6 @@ pub async fn set_archived( active.update(db).await.map_err(Into::into) } -#[allow(dead_code)] -pub async fn soft_delete( - db: &C, - project: project::Model, -) -> anyhow::Result { - soft_delete_at(db, project, OffsetDateTime::now_utc()).await -} - pub async fn soft_delete_at( db: &C, project: project::Model, @@ -191,7 +183,6 @@ pub async fn hard_delete(db: &C, project_id: Uuid) -> anyhow } /// Validates that a project is in a state that accepts new deployments. -#[allow(dead_code)] // Wired by deployment creation in Milestone 5. pub fn ensure_deployable(project: &project::Model) -> Result<(), ProjectStateError> { if project.archived_at.is_some() { return Err(ProjectStateError::Archived); diff --git a/apps/control-api/src/domain/teams.rs b/apps/control-api/src/domain/teams.rs index 5fe9182..acf5a39 100644 --- a/apps/control-api/src/domain/teams.rs +++ b/apps/control-api/src/domain/teams.rs @@ -302,7 +302,6 @@ pub async fn update( active.update(db).await.map_err(Into::into) } -#[allow(dead_code)] pub async fn soft_delete(db: &DatabaseConnection, team_id: Uuid) -> anyhow::Result<()> { let team = get_by_id(db, team_id) .await? diff --git a/apps/control-api/src/features/actions.rs b/apps/control-api/src/features/actions.rs deleted file mode 100644 index 8b13789..0000000 --- a/apps/control-api/src/features/actions.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/control-api/src/features/api/v1/projects.rs b/apps/control-api/src/features/api/v1/projects.rs index 9dc4d63..1bd42ea 100644 --- a/apps/control-api/src/features/api/v1/projects.rs +++ b/apps/control-api/src/features/api/v1/projects.rs @@ -143,8 +143,6 @@ pub(crate) struct ProjectAccess { pub project: project::Model, pub team: team::Model, pub role: TeamMemberRole, - #[allow(dead_code)] // Read by deployment slices in Milestone 5. - pub user_id: Uuid, } pub(crate) fn validate_repository_url(value: &str) -> Result<(), &'static str> { @@ -238,7 +236,6 @@ pub(crate) async fn project_access( project, team, role, - user_id: session.data.user_id, }) } diff --git a/apps/control-api/src/features/mod.rs b/apps/control-api/src/features/mod.rs index 20d53a4..2c4f36e 100644 --- a/apps/control-api/src/features/mod.rs +++ b/apps/control-api/src/features/mod.rs @@ -1,4 +1,3 @@ -pub mod actions; pub mod api; pub mod frontend; pub mod router; diff --git a/apps/control-api/src/infra/database/migrate.rs b/apps/control-api/src/infra/database/migrate.rs index 9ce86b4..177ea35 100644 --- a/apps/control-api/src/infra/database/migrate.rs +++ b/apps/control-api/src/infra/database/migrate.rs @@ -216,7 +216,7 @@ mod tests { INSERT INTO managed_certificates (id, ingress_id, host_binding_id, hostname, issuer, generation, challenge_method) VALUES ('{0}', '{entry_id}', '{0}', 'legacy.example.org', 'letsencrypt', '{0}', 'dns01'); "#, old.id)).await?; Migrator::up(db, None).await?; - assert_migration_tracking(db, 34, 0).await?; + assert_migration_tracking(db, 34).await?; ensure!(managed_certificate::Entity::find_by_id(entry_id).one(db).await?.is_none(), "entry certificate must be removed"); let legacy = managed_certificate::Entity::find_by_id(old.id).one(db).await?.unwrap(); ensure!(legacy.challenge_method == "http01" && legacy.contact_email == "owner@example.org"); @@ -306,9 +306,9 @@ mod tests { server.abort(); // Down/up restores the legacy shape, while reapplication produces the same new constraints. Migrator::down(db, Some(1)).await?; - assert_migration_tracking(db, 33, 1).await?; + assert_migration_tracking(db, 33).await?; Migrator::up(db, None).await?; - assert_migration_tracking(db, 34, 0).await?; + assert_migration_tracking(db, 34).await?; Ok(()) }.await; database.cleanup().await?; @@ -477,39 +477,39 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(31)).await?; - assert_migration_tracking(&test_db.db, 31, 0).await?; + assert_migration_tracking(&test_db.db, 31).await?; assert_avatar_schema(&test_db.db).await?; assert_screenshot_schema(&test_db.db).await?; assert_object_storage_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 30, 1).await?; + assert_migration_tracking(&test_db.db, 30).await?; assert_regional_ingress_lifecycle_absent(&test_db.db).await?; assert_avatar_schema(&test_db.db).await?; assert_screenshot_schema(&test_db.db).await?; assert_object_storage_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 29, 2).await?; + assert_migration_tracking(&test_db.db, 29).await?; assert_regional_ingress_schema_absent(&test_db.db).await?; assert_avatar_schema(&test_db.db).await?; assert_screenshot_schema(&test_db.db).await?; assert_object_storage_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 28, 3).await?; + assert_migration_tracking(&test_db.db, 28).await?; assert_avatar_schema(&test_db.db).await?; assert_screenshot_schema(&test_db.db).await?; assert_object_storage_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 27, 4).await?; + assert_migration_tracking(&test_db.db, 27).await?; assert_avatar_schema(&test_db.db).await?; assert_screenshot_schema(&test_db.db).await?; assert_object_storage_schema_absent(&test_db.db).await?; Migrator::up(&test_db.db, Some(4)).await?; - assert_migration_tracking(&test_db.db, 31, 0).await?; + assert_migration_tracking(&test_db.db, 31).await?; assert_avatar_schema(&test_db.db).await?; assert_screenshot_schema(&test_db.db).await?; assert_object_storage_schema(&test_db.db).await @@ -537,19 +537,19 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(31)).await?; - assert_migration_tracking(&test_db.db, 31, 0).await?; + assert_migration_tracking(&test_db.db, 31).await?; assert_regional_ingress_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 30, 1).await?; + assert_migration_tracking(&test_db.db, 30).await?; assert_regional_ingress_lifecycle_absent(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 29, 2).await?; + assert_migration_tracking(&test_db.db, 29).await?; assert_regional_ingress_schema_absent(&test_db.db).await?; Migrator::up(&test_db.db, Some(2)).await?; - assert_migration_tracking(&test_db.db, 31, 0).await?; + assert_migration_tracking(&test_db.db, 31).await?; assert_regional_ingress_schema(&test_db.db).await } .await; @@ -575,15 +575,15 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(25)).await?; - assert_migration_tracking(&test_db.db, 25, 0).await?; + assert_migration_tracking(&test_db.db, 25).await?; assert_registration_allowlist_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 24, 1).await?; + assert_migration_tracking(&test_db.db, 24).await?; assert_registration_allowlist_schema_absent(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 25, 0).await?; + assert_migration_tracking(&test_db.db, 25).await?; assert_registration_allowlist_schema(&test_db.db).await } .await; @@ -609,23 +609,23 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(19)).await?; - assert_migration_tracking(&test_db.db, 19, 2).await?; + assert_migration_tracking(&test_db.db, 19).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 20, 1).await?; + assert_migration_tracking(&test_db.db, 20).await?; assert_notification_content_schema(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 21, 0).await?; + assert_migration_tracking(&test_db.db, 21).await?; assert_announcement_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 20, 1).await?; + assert_migration_tracking(&test_db.db, 20).await?; assert_announcement_schema_absent(&test_db.db).await?; assert_notification_content_schema(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 21, 0).await?; + assert_migration_tracking(&test_db.db, 21).await?; assert_announcement_schema(&test_db.db).await } .await; @@ -672,15 +672,15 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(15)).await?; - assert_migration_tracking(&test_db.db, 15, 2).await?; + assert_migration_tracking(&test_db.db, 15).await?; assert_node_deletion_schema(&test_db.db).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 14, 3).await?; + assert_migration_tracking(&test_db.db, 14).await?; assert_node_deletion_schema_absent(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 15, 2).await?; + assert_migration_tracking(&test_db.db, 15).await?; assert_node_deletion_schema(&test_db.db).await } .await; @@ -706,19 +706,19 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(16)).await?; - assert_migration_tracking(&test_db.db, 16, 1).await?; + assert_migration_tracking(&test_db.db, 16).await?; let (user_id, project_id) = seed_project_notification_fixture(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 17, 0).await?; + assert_migration_tracking(&test_db.db, 17).await?; assert_project_notification_schema(&test_db.db, user_id, project_id).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 16, 1).await?; + assert_migration_tracking(&test_db.db, 16).await?; assert_project_notification_schema_absent(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 17, 0).await?; + assert_migration_tracking(&test_db.db, 17).await?; assert_project_notification_schema(&test_db.db, user_id, project_id).await } .await; @@ -744,19 +744,19 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(21)).await?; - assert_migration_tracking(&test_db.db, 21, 1).await?; + assert_migration_tracking(&test_db.db, 21).await?; let user_id = seed_authentication_fixture(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 22, 0).await?; + assert_migration_tracking(&test_db.db, 22).await?; assert_authentication_schema(&test_db.db, user_id).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 21, 1).await?; + assert_migration_tracking(&test_db.db, 21).await?; assert_authentication_schema_absent(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 22, 0).await?; + assert_migration_tracking(&test_db.db, 22).await?; assert_authentication_schema(&test_db.db, user_id).await } .await; @@ -782,19 +782,19 @@ mod tests { let verification = async { Migrator::up(&test_db.db, Some(22)).await?; - assert_migration_tracking(&test_db.db, 22, 1).await?; + assert_migration_tracking(&test_db.db, 22).await?; let user_id = seed_legacy_mfa_policy_fixture(&test_db.db).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 23, 0).await?; + assert_migration_tracking(&test_db.db, 23).await?; assert_mfa_policy_schema(&test_db.db, user_id).await?; Migrator::down(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 22, 1).await?; + assert_migration_tracking(&test_db.db, 22).await?; assert_mfa_policy_schema_absent(&test_db.db, user_id).await?; Migrator::up(&test_db.db, Some(1)).await?; - assert_migration_tracking(&test_db.db, 23, 0).await?; + assert_migration_tracking(&test_db.db, 23).await?; assert_mfa_policy_schema(&test_db.db, user_id).await } .await; @@ -1801,7 +1801,7 @@ SELECT async fn verify_audit_foundation_migration(db: &DatabaseConnection) -> anyhow::Result<()> { Migrator::up(db, Some(11)).await?; - assert_migration_tracking(db, 11, 6).await?; + assert_migration_tracking(db, 11).await?; let user_id = Uuid::now_v7(); let team_id = Uuid::now_v7(); @@ -1810,7 +1810,7 @@ SELECT seed_v11_audit_fixtures(db, user_id, team_id, project_id, deployment_id).await?; Migrator::up(db, Some(1)).await?; - assert_migration_tracking(db, 12, 5).await?; + assert_migration_tracking(db, 12).await?; assert_audit_enum_shapes(db).await?; assert_audit_column_shapes(db).await?; assert_audit_constraints(db).await?; @@ -1818,11 +1818,11 @@ SELECT assert_audit_backfill(db, deployment_id).await?; Migrator::down(db, Some(1)).await?; - assert_migration_tracking(db, 11, 6).await?; + assert_migration_tracking(db, 11).await?; assert_audit_foundation_objects_absent(db).await?; Migrator::up(db, None).await?; - assert_migration_tracking(db, 17, 0).await?; + assert_migration_tracking(db, 17).await?; assert_audit_foundation_objects_restored(db).await?; Ok(()) @@ -1879,7 +1879,6 @@ VALUES ( async fn assert_migration_tracking( db: &DatabaseConnection, applied_count: usize, - _phase_pending_count: usize, ) -> anyhow::Result<()> { let applied = Migrator::get_applied_migrations(db).await?; let pending = Migrator::get_pending_migrations(db).await?; diff --git a/apps/control-api/src/infra/host_provision/cloudflare.rs b/apps/control-api/src/infra/host_provision/cloudflare.rs index 255d08c..e023031 100644 --- a/apps/control-api/src/infra/host_provision/cloudflare.rs +++ b/apps/control-api/src/infra/host_provision/cloudflare.rs @@ -40,14 +40,6 @@ impl CloudflareConfig { Self::from_json(&source.config) } - pub fn for_txt(&self, value: &str) -> Self { - let mut config = self.clone(); - config.record_type = "TXT".to_owned(); - config.record_value = value.to_owned(); - config.proxied = false; - config - } - pub fn from_json(config: &serde_json::Value) -> Result { let object = config .as_object() @@ -212,24 +204,6 @@ impl CloudflareDns { } } - pub async fn ensure_txt_record( - &self, - config: &CloudflareConfig, - name: &str, - value: &str, - ) -> Result { - self.ensure_record(&config.for_txt(value), name).await - } - - pub async fn remove_txt_record( - &self, - config: &CloudflareConfig, - name: &str, - value: &str, - ) -> Result, HostProvisionError> { - self.remove_record(&config.for_txt(value), name).await - } - async fn parse( response: reqwest::Response, ) -> Result, HostProvisionError> { @@ -257,7 +231,7 @@ impl CloudflareDns { updated: false, }), CreateOutcome::AlreadyExists => { - let existing = self.find_record(config, host, config.record_type == "TXT").await?.ok_or_else(|| { + let existing = self.find_record(config, host, false).await?.ok_or_else(|| { HostProvisionError::Provider(format!( "cloudflare reports an existing record for {host}, but none was found in the zone" )) @@ -626,80 +600,7 @@ mod tests { } #[tokio::test] - async fn txt_reconciliation_and_cleanup_find_the_exact_value_across_pages() { - type Requests = Arc>>; - let requests = Requests::default(); - let router = Router::new() - .route("/zones/zone1/dns_records", post(|| async { - Json(failure(81_057, "Record already exists.")) - }).get(|State(requests): State, axum::extract::Query(query): axum::extract::Query>| async move { - let page = query.get("page").unwrap(); - requests.lock().unwrap().push(format!("page-{page}")); - let records = if page == "1" { - json!([ - {"id": "other-challenge", "type": "TXT", "name": "_acme-challenge.example.com", "content": "another-value"}, - {"id": "other-name", "type": "TXT", "name": "other.example.com", "content": "desired"}, - {"id": "other-type", "type": "A", "name": "_acme-challenge.example.com", "content": "desired"} - ]) - } else { - json!([{"id": "desired-record", "type": "TXT", "name": "_ACME-CHALLENGE.example.com.", "content": "desired"}]) - }; - Json(json!({"success": true, "result": records, "result_info": {"total_pages": 2}})) - })) - .route("/zones/zone1/dns_records/{id}", delete(|State(requests): State, axum::extract::Path(id): axum::extract::Path| async move { - requests.lock().unwrap().push(format!("delete-{id}")); - Json(envelope(json!({"id": id}))) - })) - .with_state(requests.clone()); - let dns = CloudflareDns::with_base_url(spawn(router).await); - let ensured = dns - .ensure_txt_record(&config(), "_acme-challenge.example.com", "desired") - .await - .unwrap(); - assert_eq!(ensured.id, "desired-record"); - assert!(!ensured.updated); - assert_eq!( - dns.remove_txt_record(&config(), "_acme-challenge.example.com", "desired") - .await - .unwrap() - .as_deref(), - Some("desired-record") - ); - assert_eq!( - requests.lock().unwrap().as_slice(), - [ - "page-1", - "page-2", - "page-1", - "page-2", - "delete-desired-record" - ] - ); - } - - #[tokio::test] - async fn missing_txt_value_never_overwrites_or_deletes_another_challenge() { - let router = Router::new().route("/zones/zone1/dns_records", post(|| async { - Json(failure(81_057, "Record already exists.")) - }).get(|| async { Json(envelope(json!([{ - "id": "unrelated", "type": "TXT", "name": "_acme-challenge.example.com", "content": "keep-me" - }]))) })); - let dns = CloudflareDns::with_base_url(spawn(router).await); - assert!( - dns.ensure_txt_record(&config(), "_acme-challenge.example.com", "new-value") - .await - .is_err() - ); - assert_eq!( - dns.remove_txt_record(&config(), "_acme-challenge.example.com", "new-value") - .await - .unwrap(), - None - ); - } - - #[tokio::test] - async fn create_retries_a_transient_response_and_uses_unproxied_txt_content() { + async fn create_retries_a_transient_response_and_preserves_record_configuration() { let calls = Arc::new(Mutex::new(0)); let router = Router::new().route("/zones/zone1/dns_records", post(|State(calls): State>>, Json(body): Json| async move { let mut calls = calls.lock().unwrap(); @@ -707,16 +608,16 @@ mod tests { if *calls == 1 { return (axum::http::StatusCode::SERVICE_UNAVAILABLE, Json(failure(1000, "retry"))); } - assert_eq!(body["type"], "TXT"); - assert_eq!(body["content"], "challenge"); - assert_eq!(body["proxied"], false); - (axum::http::StatusCode::OK, Json(envelope(json!({"id": "created", "type": "TXT", "name": "_acme-challenge.example.com", "content": "challenge"})))) + assert_eq!(body["type"], "A"); + assert_eq!(body["content"], "203.0.113.7"); + assert_eq!(body["proxied"], true); + (axum::http::StatusCode::OK, Json(envelope(json!({"id": "created", "type": "A", "name": "www.example.com", "content": "203.0.113.7"})))) })).with_state(calls.clone()); let mut config = config(); config.proxied = true; assert_eq!( CloudflareDns::with_base_url(spawn(router).await) - .ensure_txt_record(&config, "_acme-challenge.example.com", "challenge") + .ensure_record(&config, "www.example.com") .await .unwrap() .id, diff --git a/apps/control-api/src/infra/host_provision/dnspod.rs b/apps/control-api/src/infra/host_provision/dnspod.rs index 1cb5989..74e58d1 100644 --- a/apps/control-api/src/infra/host_provision/dnspod.rs +++ b/apps/control-api/src/infra/host_provision/dnspod.rs @@ -39,14 +39,6 @@ impl DnsPodConfig { Self::from_json(&source.base_domain, &source.config) } - #[allow(dead_code)] - pub fn for_txt(&self, value: &str) -> Self { - let mut config = self.clone(); - config.record_type = "TXT".to_owned(); - config.record_value = value.to_owned(); - config - } - pub fn from_json(base_domain: &str, config: &Value) -> Result { let object = config .as_object() @@ -478,26 +470,6 @@ impl DnsPod { .map(|_| ()) } - #[allow(dead_code)] - pub async fn ensure_txt_record( - &self, - config: &DnsPodConfig, - name: &str, - value: &str, - ) -> Result { - self.ensure_record(&config.for_txt(value), name).await - } - - #[allow(dead_code)] - pub async fn remove_txt_record( - &self, - config: &DnsPodConfig, - name: &str, - value: &str, - ) -> Result, HostProvisionError> { - self.remove_record(&config.for_txt(value), name).await - } - pub async fn ensure_record( &self, config: &DnsPodConfig, @@ -519,8 +491,6 @@ impl DnsPod { }); let existing = if let Some(index) = exact { Some(matching.swap_remove(index)) - } else if config.record_type == "TXT" { - None } else { matching.into_iter().next() }; @@ -556,7 +526,7 @@ impl DnsPod { return Ok(EnsuredRecord { id, updated: false }); }; if same_record_value(&config.record_type, &existing.value, &config.record_value) - && (config.record_type == "TXT" || existing.ttl == config.ttl) + && existing.ttl == config.ttl { return Ok(EnsuredRecord { id: existing.id, @@ -776,71 +746,6 @@ mod tests { ); } - #[tokio::test] - async fn txt_lifecycle_preserves_other_values_names_and_lines_across_pages() { - type Records = Arc>>; - let mut initial = vec![ - json!({"RecordId": 1, "Name": "_acme-challenge", "Type": "TXT", "Value": "other-challenge", "Line": "默认", "TTL": 600}), - json!({"RecordId": 2, "Name": "other", "Type": "TXT", "Value": "desired", "Line": "默认", "TTL": 600}), - json!({"RecordId": 3, "Name": "_acme-challenge", "Type": "TXT", "Value": "desired", "Line": "overseas", "TTL": 600}), - ]; - initial.extend((0..97).map(|index| json!({"RecordId": 100 + index, "Name": "_acme-challenge", "Type": "TXT", "Value": format!("other-{index}"), "Line": "默认", "TTL": 600}))); - let records = Arc::new(Mutex::new(initial.clone())); - let router = Router::new().route("/", post(|State(records): State, headers: HeaderMap, Json(body): Json| async move { - let mut records = records.lock().unwrap(); - match headers["x-tc-action"].to_str().unwrap() { - "DescribeRecordList" => { - let offset = body["Offset"].as_u64().unwrap() as usize; - let page = records.iter().skip(offset).take(100).cloned().collect::>(); - Json(json!({"Response": {"RecordCountInfo": {"ListCount": page.len(), "TotalCount": records.len()}, "RecordList": page}})) - } - "CreateRecord" => { - assert_eq!(body["RecordType"], "TXT"); - assert_eq!(body["Value"], "desired"); - records.push(json!({"RecordId": 9, "Name": "_ACME-CHALLENGE", "Type": "TXT", "Value": "desired", "Line": "默认", "TTL": 600})); - Json(json!({"Response": {"RecordId": 9}})) - } - "DeleteRecord" => { - assert_eq!(body["RecordId"], 9); - records.retain(|record| record["RecordId"] != 9); - Json(json!({"Response": {}})) - } - action => panic!("unexpected mutation: {action}"), - } - })).with_state(records.clone()); - let endpoint = spawn(router).await; - let dns = DnsPod::with_base_url(endpoint.clone()); - let config = config(&endpoint); - assert_eq!( - dns.ensure_txt_record(&config, "_acme-challenge.example.com", "desired") - .await - .unwrap() - .id, - "9" - ); - assert!( - !dns.ensure_txt_record(&config, "_acme-challenge.example.com", "desired") - .await - .unwrap() - .updated - ); - assert_eq!(records.lock().unwrap().len(), 101); - assert_eq!( - dns.remove_txt_record(&config, "_acme-challenge.example.com", "desired") - .await - .unwrap() - .as_deref(), - Some("9") - ); - assert_eq!(*records.lock().unwrap(), initial); - assert_eq!( - dns.remove_txt_record(&config, "_acme-challenge.example.com", "desired") - .await - .unwrap(), - None - ); - } - #[tokio::test] async fn no_data_error_is_an_empty_zone_and_allows_initial_creation() { let router = Router::new().route("/", post(|headers: HeaderMap| async move { @@ -854,14 +759,14 @@ mod tests { let endpoint = spawn(router).await; let dns = DnsPod::with_base_url(endpoint.clone()); assert_eq!( - dns.ensure_txt_record(&config(&endpoint), "_acme-challenge.example.com", "value") + dns.ensure_record(&config(&endpoint), "www.example.com") .await .unwrap() .id, "5" ); assert_eq!( - dns.remove_txt_record(&config(&endpoint), "_acme-challenge.example.com", "value") + dns.remove_record(&config(&endpoint), "www.example.com") .await .unwrap(), None @@ -905,7 +810,7 @@ mod tests { let router = Router::new().route("/", post(|State(created): State>>, headers: HeaderMap| async move { let mut created = created.lock().unwrap(); Json(if headers["x-tc-action"] == "DescribeRecordList" { - json!({"Response": {"RecordList": if *created { vec![json!({"RecordId": 42, "Name": "_acme-challenge", "Type": "TXT", "Value": "desired", "Line": "默认", "TTL": 300})] } else { vec![] }}}) + json!({"Response": {"RecordList": if *created { vec![json!({"RecordId": 42, "Name": "www", "Type": "A", "Value": "203.0.113.7", "Line": "默认", "TTL": 300})] } else { vec![] }}}) } else { assert_eq!(headers["x-tc-action"], "CreateRecord"); *created = true; @@ -915,7 +820,7 @@ mod tests { let endpoint = spawn(router).await; assert_eq!( DnsPod::with_base_url(endpoint.clone()) - .ensure_txt_record(&config(&endpoint), "_acme-challenge.example.com", "desired") + .ensure_record(&config(&endpoint), "www.example.com") .await .unwrap() .id, diff --git a/apps/control-api/src/infra/host_provision/mod.rs b/apps/control-api/src/infra/host_provision/mod.rs index 797442b..af684a4 100644 --- a/apps/control-api/src/infra/host_provision/mod.rs +++ b/apps/control-api/src/infra/host_provision/mod.rs @@ -164,77 +164,6 @@ impl DnsProviderHostProvisioner { } } - #[allow(dead_code)] - pub async fn ensure_txt_record( - &self, - provider: &str, - config: &serde_json::Value, - zone: &str, - name: &str, - value: &str, - ) -> Result { - let result = match provider.trim().to_ascii_lowercase().as_str() { - cloudflare::PROVIDER_NAME => { - let parsed = cloudflare::CloudflareConfig::from_json(&txt_config(config, value)) - .map_err(HostProvisionError::Provider)?; - self.cloudflare - .ensure_txt_record(&parsed, name, value) - .await - .map(|record| record.id) - } - dnspod::PROVIDER_NAME => { - let parsed = dnspod::DnsPodConfig::from_json(zone, &txt_config(config, value)) - .map_err(HostProvisionError::Provider)?; - self.dnspod - .ensure_txt_record(&parsed, name, value) - .await - .map(|record| record.id) - } - route53::PROVIDER_NAME => { - let parsed = route53::Route53Config::from_json(&txt_config(config, value)) - .map_err(HostProvisionError::Provider)?; - self.route53 - .ensure_txt_record(&parsed, name, value) - .await - .map(|record| record.id) - } - other => Err(Self::unsupported(Some(other))), - }; - result.map_err(|error| credentials::redact_error(config, error)) - } - - #[allow(dead_code)] - pub async fn remove_txt_record( - &self, - provider: &str, - config: &serde_json::Value, - zone: &str, - name: &str, - value: &str, - ) -> Result, HostProvisionError> { - let result = match provider.trim().to_ascii_lowercase().as_str() { - cloudflare::PROVIDER_NAME => { - let parsed = cloudflare::CloudflareConfig::from_json(&txt_config(config, value)) - .map_err(HostProvisionError::Provider)?; - self.cloudflare - .remove_txt_record(&parsed, name, value) - .await - } - dnspod::PROVIDER_NAME => { - let parsed = dnspod::DnsPodConfig::from_json(zone, &txt_config(config, value)) - .map_err(HostProvisionError::Provider)?; - self.dnspod.remove_txt_record(&parsed, name, value).await - } - route53::PROVIDER_NAME => { - let parsed = route53::Route53Config::from_json(&txt_config(config, value)) - .map_err(HostProvisionError::Provider)?; - self.route53.remove_txt_record(&parsed, name, value).await - } - other => Err(Self::unsupported(Some(other))), - }; - result.map_err(|error| credentials::redact_error(config, error)) - } - fn unsupported(provider: Option<&str>) -> HostProvisionError { HostProvisionError::UnsupportedSource(format!( "dns provider '{}' is not supported (supported: {})", @@ -244,17 +173,6 @@ impl DnsProviderHostProvisioner { } } -/// Add the record fields required by the provider parsers without requiring -/// ACME configuration to contain a project-host record template. -fn txt_config(config: &serde_json::Value, value: &str) -> serde_json::Value { - let mut object = config.as_object().cloned().unwrap_or_default(); - // Provider parsers validate the base host-source template. The concrete - // TXT methods call `for_txt` after parsing and replace this placeholder. - object.insert("record_type".to_owned(), serde_json::json!("CNAME")); - object.insert("record_value".to_owned(), serde_json::json!(value)); - serde_json::Value::Object(object) -} - pub fn supported_provider_names() -> &'static str { "cloudflare, dnspod, route53" } @@ -508,16 +426,4 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("api_token"), "{error}"); } - - #[test] - fn acme_txt_config_keeps_provider_parsers_on_a_valid_base_template() { - let config = txt_config( - &serde_json::json!({ "api_token": "token", "zone_id": "zone" }), - "challenge", - ); - let parsed = cloudflare::CloudflareConfig::from_json(&config).unwrap(); - - assert_eq!(parsed.record_type, "CNAME"); - assert_eq!(parsed.record_value, "challenge"); - } } diff --git a/apps/control-api/src/infra/host_provision/route53.rs b/apps/control-api/src/infra/host_provision/route53.rs index 132e95a..e4d09b2 100644 --- a/apps/control-api/src/infra/host_provision/route53.rs +++ b/apps/control-api/src/infra/host_provision/route53.rs @@ -42,14 +42,6 @@ impl Route53Config { Self::from_json(&source.config) } - #[allow(dead_code)] - pub fn for_txt(&self, value: &str) -> Self { - let mut config = self.clone(); - config.record_type = "TXT".to_owned(); - config.record_value = value.to_owned(); - config - } - pub fn from_json(config: &Value) -> Result { let object = config .as_object() @@ -365,97 +357,6 @@ fn aws_uri_encode(value: &str) -> String { .collect() } -/// Route53 TXT RDATA consists of quoted strings of at most 255 bytes. -fn encode_txt(value: &str) -> String { - let mut chunks = Vec::new(); - let mut chunk = String::new(); - let mut bytes = 0; - for character in value.chars() { - if bytes + character.len_utf8() > 255 { - chunks.push(format!("\"{chunk}\"")); - chunk.clear(); - bytes = 0; - } - bytes += character.len_utf8(); - match character { - '"' | '\\' => { - chunk.push('\\'); - chunk.push(character); - } - character if character.is_control() => { - for byte in character.to_string().bytes() { - chunk.push_str(&format!("\\{byte:03o}")); - } - } - character => chunk.push(character), - } - } - chunks.push(format!("\"{chunk}\"")); - chunks.join(" ") -} - -fn decode_txt(value: &str) -> Option { - let bytes = value.as_bytes(); - let mut output = Vec::new(); - let mut position = 0; - let mut chunks = 0; - while position < bytes.len() { - if bytes[position].is_ascii_whitespace() { - position += 1; - continue; - } - if bytes[position] != b'"' { - return None; - } - position += 1; - chunks += 1; - loop { - let byte = *bytes.get(position)?; - position += 1; - match byte { - b'"' => break, - b'\\' => { - let escaped = *bytes.get(position)?; - if (b'0'..=b'7').contains(&escaped) { - let digits = bytes.get(position..position + 3)?; - if !digits.iter().all(|byte| (b'0'..=b'7').contains(byte)) { - return None; - } - let decoded = (u16::from(digits[0] - b'0') << 6) - + (u16::from(digits[1] - b'0') << 3) - + u16::from(digits[2] - b'0'); - output.push(u8::try_from(decoded).ok()?); - position += 3; - } else { - output.push(escaped); - position += 1; - } - } - byte => output.push(byte), - } - } - } - (chunks > 0) - .then(|| String::from_utf8(output).ok()) - .flatten() -} - -fn value_matches(config: &Route53Config, value: &str) -> bool { - if config.record_type == "TXT" { - decode_txt(value).as_deref() == Some(config.record_value.as_str()) - } else { - same_record_value(&config.record_type, value, &config.record_value) - } -} - -fn desired_value(config: &Route53Config) -> String { - if config.record_type == "TXT" { - encode_txt(&config.record_value) - } else { - config.record_value.clone() - } -} - fn change_fragment(record: &RecordSet, action: &str) -> String { let values = record .values @@ -663,26 +564,6 @@ impl Route53 { .unwrap_or_else(|| format!("route53:{}", config.hosted_zone_id))) } - #[allow(dead_code)] - pub async fn ensure_txt_record( - &self, - config: &Route53Config, - name: &str, - value: &str, - ) -> Result { - self.ensure_record(&config.for_txt(value), name).await - } - - #[allow(dead_code)] - pub async fn remove_txt_record( - &self, - config: &Route53Config, - name: &str, - value: &str, - ) -> Result, HostProvisionError> { - self.remove_record(&config.for_txt(value), name).await - } - pub async fn ensure_record( &self, config: &Route53Config, @@ -700,9 +581,9 @@ impl Route53 { let contains_value = record .values .iter() - .any(|value| value_matches(config, value)); + .any(|value| same_record_value(&config.record_type, value, &config.record_value)); let matches = contains_value - && (config.record_type == "TXT" || record.ttl == config.ttl) + && record.ttl == config.ttl && (config.record_type != "CNAME" || record.values.len() == 1); if matches { return Ok(EnsuredRecord { @@ -711,13 +592,11 @@ impl Route53 { }); } if config.record_type == "CNAME" { - record.values = vec![desired_value(config)]; + record.values = vec![config.record_value.clone()]; } else if !contains_value { - record.values.push(desired_value(config)); - } - if config.record_type != "TXT" { - record.ttl = config.ttl; + record.values.push(config.record_value.clone()); } + record.ttl = config.ttl; match self .change_record_set(config, existing.as_ref(), Some(&record)) .await @@ -748,7 +627,9 @@ impl Route53 { }; let mut record = original.clone(); let original_len = record.values.len(); - record.values.retain(|value| !value_matches(config, value)); + record.values.retain(|value| { + !same_record_value(&config.record_type, value, &config.record_value) + }); if record.values.len() == original_len { return Ok(None); } @@ -1097,131 +978,6 @@ mod tests { ); } - #[test] - fn txt_rdata_round_trips_quotes_escapes_utf8_and_multiple_chunks() { - for value in [ - String::new(), - "text with \"quotes\" and \\backslash\n".to_owned(), - "a".repeat(256), - "证书".repeat(60), - ] { - let encoded = encode_txt(&value); - assert_eq!(decode_txt(&encoded).as_deref(), Some(value.as_str())); - assert!(encoded.starts_with('"') && encoded.ends_with('"')); - } - assert_eq!( - encode_txt(&"a".repeat(256)), - format!("\"{}\" \"a\"", "a".repeat(255)) - ); - assert_eq!( - decode_txt("\"first\" \"second\""), - Some("firstsecond".to_owned()) - ); - assert_eq!(decode_txt("unquoted"), None); - assert_eq!(decode_txt("\"unfinished"), None); - assert_eq!(aws_uri_encode("*.example.com ~"), "%2A.example.com%20~"); - } - - #[tokio::test] - async fn txt_lifecycle_retries_conflicts_and_preserves_other_challenges() { - let original = RecordSet { - name: "_acme-challenge.example.com.".to_owned(), - record_type: "TXT".to_owned(), - ttl: 60, - values: vec!["\"keep-me\"".to_owned()], - }; - let mut concurrent = original.clone(); - concurrent.values.push("\"other-challenge\"".to_owned()); - let zone = Arc::new(Mutex::new(MockZone { - record: Some(original), - replace_on_next_write: Some(concurrent.clone()), - requests: Vec::new(), - })); - let endpoint = mock_zone(zone.clone()).await; - let config = config(&endpoint); - let dns = Route53::with_base_url(endpoint); - let value = "new-\"challenge\"\\value"; - assert!( - dns.ensure_txt_record(&config, "_acme-challenge.example.com", value) - .await - .unwrap() - .updated - ); - let expected = { - let mut expected = concurrent.clone(); - expected.values.push(encode_txt(value)); - expected - }; - assert_eq!(zone.lock().unwrap().record, Some(expected)); - assert_eq!(zone.lock().unwrap().requests.len(), 2); - assert!( - !dns.ensure_txt_record(&config, "_acme-challenge.example.com", value) - .await - .unwrap() - .updated - ); - assert_eq!(zone.lock().unwrap().requests.len(), 2); - assert!( - dns.remove_txt_record(&config, "_acme-challenge.example.com", value) - .await - .unwrap() - .is_some() - ); - assert_eq!(zone.lock().unwrap().record, Some(concurrent)); - assert_eq!( - dns.remove_txt_record(&config, "_acme-challenge.example.com", value) - .await - .unwrap(), - None - ); - } - - #[tokio::test] - async fn simultaneous_txt_ensures_keep_both_values() { - let zone = Arc::new(Mutex::new(MockZone::default())); - let endpoint = mock_zone(zone.clone()).await; - let config = config(&endpoint); - let dns = Route53::with_base_url(endpoint); - let (first, second) = tokio::join!( - dns.ensure_txt_record(&config, "_acme-challenge.example.com", "first"), - dns.ensure_txt_record(&config, "_acme-challenge.example.com", "second") - ); - first.unwrap(); - second.unwrap(); - let mut values = zone.lock().unwrap().record.as_ref().unwrap().values.clone(); - values.sort(); - assert_eq!(values, ["\"first\"", "\"second\""]); - } - - #[tokio::test] - async fn last_txt_cleanup_deletes_the_original_chunked_rdata_exactly() { - let zone = Arc::new(Mutex::new(MockZone { - record: Some(RecordSet { - name: "_acme-challenge.example.com.".to_owned(), - record_type: "TXT".to_owned(), - ttl: 60, - values: vec!["\"first\" \"second\"".to_owned()], - }), - ..MockZone::default() - })); - let endpoint = mock_zone(zone.clone()).await; - let config = config(&endpoint); - let dns = Route53::with_base_url(endpoint); - assert!( - dns.remove_txt_record(&config, "_acme-challenge.example.com", "firstsecond") - .await - .unwrap() - .is_some() - ); - assert!(zone.lock().unwrap().record.is_none()); - assert_eq!( - dns.remove_txt_record(&config, "_acme-challenge.example.com", "firstsecond") - .await - .unwrap(), - None - ); - } - #[test] fn closest_next_record_proves_absence_and_routing_policies_are_protected() { let xml = "next.example.com.Atruez.example.com."; diff --git a/apps/control-api/src/infra/quota/mod.rs b/apps/control-api/src/infra/quota/mod.rs index e3f2853..ffd035e 100644 --- a/apps/control-api/src/infra/quota/mod.rs +++ b/apps/control-api/src/infra/quota/mod.rs @@ -33,7 +33,6 @@ const STATIC_COUNTER_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 30); const MONTHLY_COUNTER_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 40); /// A concurrent-build slot must be refreshed by the heartbeat of the running /// build; expired slots free themselves after a crashed Node stops renewing. -#[allow(dead_code)] // Wired by the Node claim flow in Milestone 6. pub const BUILD_SLOT_TTL: Duration = Duration::from_secs(60 * 30); pub struct QuotaCharge { @@ -49,7 +48,6 @@ impl QuotaCharge { } } - #[allow(dead_code)] // Wired by build-minute and storage charges in Milestone 7. pub fn amount(dimension: QuotaDimension, amount: i64) -> Self { Self { dimension, amount } } @@ -337,7 +335,6 @@ impl<'a> QuotaService<'a> { /// Reads a scalar (non-counted) limit such as the build timeout or the /// per-artifact size limit. `None` means unlimited. - #[allow(dead_code)] // Wired by build and artifact limits in Milestones 6 and 7. pub async fn scalar_limit( &self, op: &'static str, @@ -353,7 +350,6 @@ impl<'a> QuotaService<'a> { /// Acquires one concurrent-build slot for a team. Returns whether the /// slot was acquired. Slots expire after [`BUILD_SLOT_TTL`] unless /// refreshed, so crashed Nodes cannot pin slots forever. - #[allow(dead_code)] // Wired by the Node claim flow in Milestone 6. pub async fn acquire_build_slot( &self, op: &'static str, @@ -385,7 +381,6 @@ impl<'a> QuotaService<'a> { Ok(acquired) } - #[allow(dead_code)] // Wired by the Node claim flow in Milestone 6. pub async fn release_build_slot(&self, team_id: Uuid) { if let Err(error) = self.cache.release_slot(&slot_key(team_id)).await { tracing::warn!( @@ -414,7 +409,6 @@ impl<'a> QuotaService<'a> { /// Refreshes the TTL of a team's build-slot counter while a build is /// still running. - #[allow(dead_code)] // Wired by the Node stage flow in Milestone 6. pub async fn refresh_build_slot(&self, team_id: Uuid) { let key = slot_key(team_id); if let Ok(Some(value)) = self.cache.get(&key).await { @@ -425,22 +419,6 @@ impl<'a> QuotaService<'a> { } } - /// Rebuilds a team's cache counters from the durable usage counters. - #[allow(dead_code)] // Wired by the calibration task in Milestone 6. - pub async fn recalibrate_team(&self, team_id: Uuid) -> anyhow::Result<()> { - for dimension in QuotaDimension::ALL { - if !dimension.is_counted() { - continue; - } - let usage = quotas::effective_usage(self.db, team_id, *dimension).await?; - let key = counter_key(team_id, *dimension); - self.cache - .set(&key, &usage.to_string(), counter_ttl(*dimension)) - .await?; - } - Ok(()) - } - async fn seed_counter( &self, team_id: Uuid, @@ -542,7 +520,6 @@ fn counter_key(team_id: Uuid, dimension: QuotaDimension) -> String { } } -#[allow(dead_code)] // Wired by the Node claim flow in Milestone 6. fn slot_key(team_id: Uuid) -> String { format!("quota:team:{team_id}:concurrent_builds") } diff --git a/apps/node/src/build/logs.rs b/apps/node/src/build/logs.rs index 746a67d..fef2a3b 100644 --- a/apps/node/src/build/logs.rs +++ b/apps/node/src/build/logs.rs @@ -91,11 +91,6 @@ impl LogCollector { ) } - #[allow(dead_code)] // Read by the websocket log pusher in Milestone 9. - pub fn deployment_id(&self) -> Uuid { - self.deployment_id - } - /// Records one log line under the given stage: persisted through the /// HTTP batch and mirrored on the realtime channel. pub fn log(&self, stage: &str, line: impl Into) { @@ -137,11 +132,6 @@ impl LogCollector { }); } } - - #[allow(dead_code)] // Read by the websocket log pusher in Milestone 9. - pub fn last_seq(&self) -> u64 { - self.seq.load(Ordering::Relaxed) - } } fn now_ms() -> i64 { diff --git a/apps/node/src/build/mod.rs b/apps/node/src/build/mod.rs index 5301951..9f0004e 100644 --- a/apps/node/src/build/mod.rs +++ b/apps/node/src/build/mod.rs @@ -18,9 +18,7 @@ use crate::{ client::ControlApiClient, config::NodeConfig, output, - runtime::{ - BuildRuntime, ContainerRuntime, ContainerRuntimeError, PrepareImageInput, RunBuildInput, - }, + runtime::{BuildRuntime, ContainerRuntime, ContainerRuntimeError, RunBuildInput}, }; const CLAIM_INTERVAL: Duration = Duration::from_secs(5); @@ -428,7 +426,7 @@ async fn run_pipeline( } }); runtime - .prepare_image(PrepareImageInput { image: &image }, log_tx.clone()) + .prepare_image(&image, log_tx.clone()) .await .map_err(|error| BuildFailure::new("image_pull_failed", error.to_string()))?; drop(log_tx); diff --git a/apps/node/src/client/mod.rs b/apps/node/src/client/mod.rs index 9b0027b..1374524 100644 --- a/apps/node/src/client/mod.rs +++ b/apps/node/src/client/mod.rs @@ -10,8 +10,8 @@ use grass_node_protocol::{ HeartbeatResponse, ObserveSshHostKeyRequest, ObserveSshHostKeyResponse, RedeemGitCredentialRequest, RedeemGitCredentialResponse, RegisterRequest, RegisterResponse, ReportIngressStatusRequest, ReportServeStatusRequest, ReportServeStatusResponse, - ResolveHostResponse, RouteSnapshotResponse, ServeAssignment, ServeAssignmentsResponse, - SsrLeaseResponse, StageRequest, StageResponse, StartPreviewAuthorizationRequest, + RouteSnapshotResponse, ServeAssignment, ServeAssignmentsResponse, SsrLeaseResponse, + StageRequest, StageResponse, StartPreviewAuthorizationRequest, StartPreviewAuthorizationResponse, UploadArtifactResponse, VerifyPreviewGrantRequest, VerifyPreviewGrantResponse, artifact_headers, }; @@ -147,13 +147,11 @@ impl ControlApiClient { .await } - #[allow(dead_code)] // Wired by the build loop in Milestone 7. pub async fn claim(&self, request: &ClaimRequest) -> anyhow::Result { self.post_json("/deployments/claim", request, "deployment.claim") .await } - #[allow(dead_code)] // Wired by the build loop in Milestone 7. pub async fn report_stage( &self, deployment_id: Uuid, @@ -193,7 +191,6 @@ impl ControlApiClient { .await } - #[allow(dead_code)] // Wired by the build loop in Milestone 7. pub async fn append_build_log( &self, deployment_id: Uuid, @@ -256,7 +253,6 @@ impl ControlApiClient { Ok(request) } - #[allow(dead_code)] // Wired by the build loop in Milestone 7. #[allow(clippy::too_many_arguments)] pub async fn upload_artifact( &self, @@ -491,24 +487,6 @@ impl ControlApiClient { Ok(()) } - #[allow(dead_code)] // Wired by the serve resolver in Milestone 10. - pub async fn resolve_host(&self, host: &str) -> anyhow::Result> { - let response = self - .http - .get(self.url("/serve/resolve-host")) - .query(&[("host", host)]) - .bearer_auth(&self.token) - .send() - .await - .context("serve.resolve_host: request failed")?; - if response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - Self::unwrap_envelope(response, "serve.resolve_host") - .await - .map(Some) - } - pub async fn start_preview_authorization( &self, host: &str, diff --git a/apps/node/src/runtime/mod.rs b/apps/node/src/runtime/mod.rs index 37fd302..3372ee6 100644 --- a/apps/node/src/runtime/mod.rs +++ b/apps/node/src/runtime/mod.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::path::PathBuf; +#[cfg(test)] use std::sync::Mutex; use std::time::Duration; @@ -29,10 +30,6 @@ pub enum ContainerRuntimeError { Runtime(String), } -pub struct PrepareImageInput<'a> { - pub image: &'a str, -} - pub struct RunBuildInput { pub image: String, /// Local workspace copied into the container at /workspace. The build @@ -95,7 +92,7 @@ pub trait ContainerRuntime: Send + Sync { /// Ensures the build image exists locally, pulling it when missing. fn prepare_image( &self, - input: PrepareImageInput<'_>, + image: &str, logs: mpsc::Sender, ) -> impl Future> + Send; @@ -136,17 +133,16 @@ pub use socket::SocketRuntime; pub enum BuildRuntime { Socket(SocketRuntime), /// Deterministic in-process fake used by tests. - #[allow(dead_code)] + #[cfg(test)] Fake(FakeRuntime), } impl BuildRuntime { pub fn from_config(config: &RuntimeConfig) -> Result { match config.backend.as_str() { - "docker-socket" | "podman-socket" => Ok(Self::Socket(SocketRuntime::connect( - &config.backend, - &config.socket, - )?)), + "docker-socket" | "podman-socket" => { + Ok(Self::Socket(SocketRuntime::connect(&config.socket)?)) + } backend @ ("apple-container" | "jail") => Err( ContainerRuntimeError::BackendNotImplemented(backend.to_owned()), ), @@ -158,12 +154,13 @@ impl BuildRuntime { impl ContainerRuntime for BuildRuntime { async fn prepare_image( &self, - input: PrepareImageInput<'_>, + image: &str, logs: mpsc::Sender, ) -> Result<(), ContainerRuntimeError> { match self { - Self::Socket(runtime) => runtime.prepare_image(input, logs).await, - Self::Fake(runtime) => runtime.prepare_image(input, logs).await, + Self::Socket(runtime) => runtime.prepare_image(image, logs).await, + #[cfg(test)] + Self::Fake(runtime) => runtime.prepare_image(image, logs).await, } } @@ -175,6 +172,7 @@ impl ContainerRuntime for BuildRuntime { ) -> Result { match self { Self::Socket(runtime) => runtime.run_build(input, logs, cancel).await, + #[cfg(test)] Self::Fake(runtime) => runtime.run_build(input, logs, cancel).await, } } @@ -185,6 +183,7 @@ impl ContainerRuntime for BuildRuntime { ) -> Result { match self { Self::Socket(runtime) => runtime.run_service(input).await, + #[cfg(test)] Self::Fake(runtime) => runtime.run_service(input).await, } } @@ -192,6 +191,7 @@ impl ContainerRuntime for BuildRuntime { async fn stop_service(&self, service_id: &str) -> Result<(), ContainerRuntimeError> { match self { Self::Socket(runtime) => runtime.stop_service(service_id).await, + #[cfg(test)] Self::Fake(runtime) => runtime.stop_service(service_id).await, } } @@ -202,6 +202,7 @@ impl ContainerRuntime for BuildRuntime { ) -> Result, ContainerRuntimeError> { match self { Self::Socket(runtime) => runtime.list_services(prefix).await, + #[cfg(test)] Self::Fake(runtime) => runtime.list_services(prefix).await, } } @@ -209,6 +210,7 @@ impl ContainerRuntime for BuildRuntime { /// Test backend: scripts map to canned exit codes and output lines, letting /// pipeline tests run without a container engine. +#[cfg(test)] #[derive(Default)] pub struct FakeRuntime { /// Exit code returned for scripts containing the key; unmatched scripts @@ -221,10 +223,11 @@ pub struct FakeRuntime { services: Mutex>>, } +#[cfg(test)] impl ContainerRuntime for FakeRuntime { async fn prepare_image( &self, - _input: PrepareImageInput<'_>, + _image: &str, _logs: mpsc::Sender, ) -> Result<(), ContainerRuntimeError> { Ok(()) diff --git a/apps/node/src/runtime/socket.rs b/apps/node/src/runtime/socket.rs index ce8514c..97ccb60 100644 --- a/apps/node/src/runtime/socket.rs +++ b/apps/node/src/runtime/socket.rs @@ -21,12 +21,11 @@ use futures_util::StreamExt; use tokio::sync::{mpsc, watch}; use super::{ - BuildExecutionResult, ContainerRuntimeError, PrepareImageInput, RunBuildInput, RunServiceInput, - RunningService, ServiceContainer, + BuildExecutionResult, ContainerRuntimeError, RunBuildInput, RunServiceInput, RunningService, + ServiceContainer, }; pub struct SocketRuntime { - backend: String, docker: Docker, } @@ -35,19 +34,11 @@ fn runtime_error(context: &str, error: impl std::fmt::Display) -> ContainerRunti } impl SocketRuntime { - pub fn connect(backend: &str, socket: &str) -> Result { + pub fn connect(socket: &str) -> Result { let path = socket.strip_prefix("unix://").unwrap_or(socket).to_owned(); let docker = Docker::connect_with_unix(&path, 120, bollard::API_DEFAULT_VERSION) .map_err(|error| runtime_error("connect", error))?; - Ok(Self { - backend: backend.to_owned(), - docker, - }) - } - - #[allow(dead_code)] // Reported in diagnostics once serve logging lands. - pub fn backend(&self) -> &str { - &self.backend + Ok(Self { docker }) } async fn remove_container(&self, name: &str) { @@ -137,19 +128,17 @@ async fn unpack_export(destination: PathBuf, bytes: Vec) -> Result<(), Conta impl super::ContainerRuntime for SocketRuntime { async fn prepare_image( &self, - input: PrepareImageInput<'_>, + image: &str, logs: mpsc::Sender, ) -> Result<(), ContainerRuntimeError> { - if self.docker.inspect_image(input.image).await.is_ok() { + if self.docker.inspect_image(image).await.is_ok() { return Ok(()); } - let _ = logs - .send(format!("pulling build image {}", input.image)) - .await; + let _ = logs.send(format!("pulling build image {}", image)).await; let mut pull = self.docker.create_image( Some(CreateImageOptions { - from_image: Some(input.image.to_owned()), + from_image: Some(image.to_owned()), ..Default::default() }), None, @@ -165,7 +154,7 @@ impl super::ContainerRuntime for SocketRuntime { } // Confirm the image exists after the pull stream completes. self.docker - .inspect_image(input.image) + .inspect_image(image) .await .map(|_| ()) .map_err(|error| runtime_error("image inspect", error)) diff --git a/apps/node/src/serve/release_smoke.rs b/apps/node/src/serve/release_smoke.rs index 6f40d64..53c26e4 100644 --- a/apps/node/src/serve/release_smoke.rs +++ b/apps/node/src/serve/release_smoke.rs @@ -28,7 +28,7 @@ use crate::{ client::ControlApiClient, config::NodeConfig, output::generate_grass_output, - runtime::{ContainerRuntime, PrepareImageInput, RunBuildInput, SocketRuntime}, + runtime::{ContainerRuntime, RunBuildInput, SocketRuntime}, serve::{ServeState, routes::RouteTable, ssr::SsrManager, sync::stage_archive}, }; @@ -248,10 +248,10 @@ async fn checks_out_builds_packages_stages_and_serves_vite() -> anyhow::Result<( "checkout resolved an unexpected commit" ); - let runtime = SocketRuntime::connect("docker-socket", &socket)?; + let runtime = SocketRuntime::connect(&socket)?; let (log_sender, mut log_receiver) = mpsc::channel(4096); runtime - .prepare_image(PrepareImageInput { image: &image }, log_sender.clone()) + .prepare_image(&image, log_sender.clone()) .await .context("build image is unavailable")?; let (_cancel_sender, cancel_receiver) = watch::channel(false); diff --git a/apps/node/src/serve/ssr.rs b/apps/node/src/serve/ssr.rs index 5a957e5..fc11d27 100644 --- a/apps/node/src/serve/ssr.rs +++ b/apps/node/src/serve/ssr.rs @@ -22,7 +22,7 @@ use crate::{ client::ControlApiClient, config::NodeConfig, output::manifest::ServerSection, - runtime::{BuildRuntime, ContainerRuntime, PrepareImageInput, RunServiceInput}, + runtime::{BuildRuntime, ContainerRuntime, RunServiceInput}, }; /// Fixed port SSR servers listen on inside their container; the manager @@ -212,7 +212,7 @@ impl SsrManager { let (pull_tx, mut pull_rx) = mpsc::channel::(8); let drain = tokio::spawn(async move { while pull_rx.recv().await.is_some() {} }); runtime - .prepare_image(PrepareImageInput { image: &self.image }, pull_tx) + .prepare_image(&self.image, pull_tx) .await .map_err(|error| anyhow::anyhow!("serve image unavailable: {error}")) .inspect_err(|_| { diff --git a/crates/archive/src/lib.rs b/crates/archive/src/lib.rs index 912f66e..933f090 100644 --- a/crates/archive/src/lib.rs +++ b/crates/archive/src/lib.rs @@ -127,11 +127,6 @@ pub fn unpack_zip(archive_path: &Path, destination: &Path) -> anyhow::Result anyhow::Result { - unpack_zip_reader(std::io::Cursor::new(bytes), destination) -} - /// Upper bounds that keep a hostile archive from exhausting disk. A single /// build artifact is expected to be far below these; they exist so a /// decompression bomb fails loudly instead of filling the host. diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index febe60e..a153319 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -10,5 +10,4 @@ rust-version.workspace = true path = "src/lib.rs" [dependencies] -mime_guess.workspace = true rust-embed.workspace = true diff --git a/docs/regional-ingress.md b/docs/regional-ingress.md index d01511a..66da1a9 100644 --- a/docs/regional-ingress.md +++ b/docs/regional-ingress.md @@ -106,7 +106,7 @@ fails must reconnect; new requests use an available entry. 1. Under **Administration → Regional ingresses**, add the Node region and an ingress hostname such as `eu.entry.example.net`. Enable the ingress and TLS, - select the certificate issuer, and configure its DNS challenge provider. + select the certificate issuer, and point its public DNS records at the entries. 2. Add the custom domain in the project's Domains page and select that region. Publish the displayed CNAME target and the exact `_grass.` TXT ownership record. The TXT value is bound to this domain binding; copy the @@ -127,21 +127,16 @@ Node polls snapshots every five seconds. An unreachable Control API preserves the last valid local certificate; explicit Node authorization revocation or an authoritative snapshot removing the hostname withdraws it. -If public port 80 cannot be exposed, select DNS-01 for the custom domain and -publish the displayed `_acme-challenge.` CNAME delegation. Its target -is specific to the binding under the regional ingress hostname. The Control -API verifies that delegation, creates the TXT value through the configured -regional DNS provider, waits for public DNS propagation, and removes that -exact value after the attempt. Preserve the delegation for automatic renewal; -update it if the regional ingress hostname changes. HTTP-01 and delegated -DNS-01 both require the independent `_grass` TXT ownership verification. +Automatic certificate issuance uses HTTP-01 for both regional ingress and +custom hostnames. Keep public port 80 reachable for issuance and renewal. +When that is unavailable, import and renew a manual certificate. Custom +domains still require the independent `_grass` TXT ownership verification. ## Issuers and renewal - **Let's Encrypt:** automatic issuance and renewal. Regional ingress - hostnames use DNS-01; custom domains default to HTTP-01 and can use the - delegated DNS-01 flow above. `contact_email` is optional account contact - configuration. + hostnames and custom domains use HTTP-01. `contact_email` is optional + account contact configuration. - **ZeroSSL:** uses the same lifecycle and also requires `eab_kid` and the base64/base64url `eab_hmac_key` in the regional account configuration. - **Manual:** import a full certificate chain and matching private key through diff --git a/docs/self-hosting.md b/docs/self-hosting.md index cbc91e6..cd3c790 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -457,8 +457,9 @@ manual certificates. Enable `[serve.tls]` on each public entry Node and use TCP passthrough at the regional load balancer. Nodes select certificates by SNI, enforce the original Host, and hot-reload validated renewals. Keep `serve.public_base_url` on private HTTP for Peer Hop and health checks. -Custom domains require TXT ownership plus review, default to HTTP-01, and -support delegated DNS-01; regional certificates use DNS-01. The Console +Custom domains require TXT ownership plus review. Automatic regional and +custom-domain certificates use HTTP-01, so public port 80 must remain reachable +for issuance and renewal. The Console reports issuance, expiry, retries and actual Node certificate revisions. See [Regional ingress and HTTPS](regional-ingress.md) for complete configuration. diff --git a/docs/todo.md b/docs/todo.md index 74970f8..7d690d4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -9,6 +9,18 @@ - 未经用户批准,不得把较低优先级或 Future 项目提前并入当前功能。 - 当前版本从本文档头部读取,并用于 GitHub Milestone 与 Project 命名。 +# 当前维护 + +## P2:代码清理 + +- P2.8 清理无用 Rust 代码、旧 DNS challenge 写入、过期 lint 豁免及未使用依赖;限定测试替身的编译范围,并简化无效包装。跟踪:#196。 +- P2.9 统一存储配置解析与 Console 字段模型,保留首次设置和在线迁移各自的事务流程。跟踪:#197。 +- P2.10 集中 HTTP 数据库和缓存依赖获取,保留操作名及错误语义。跟踪:#198。 +- P2.11 统一域名删除编排、按删除代次释放配额及路由失效通知。跟踪:#199。 +- P2.12 清理 Console 旧 API 和多余导出,复用设置开关、导航、账号菜单及安全时间展示。跟踪:#200。 + +本轮对应代码清理审查,父任务为 #195;内部模块没有仓外消费者,允许调整内部接口。 + # 第二阶段 ## P2:平台扩展 From 98e1b7f69175b90fd5d80f6b6fdfc77bb53f0285 Mon Sep 17 00:00:00 2001 From: yuanzui-cf Date: Sat, 12 Sep 2026 17:02:52 +0800 Subject: [PATCH 2/6] refactor(http): Centralize database and cache access Refs #198 --- apps/control-api/src/features/api/v1/admin.rs | 27 +++---------------- .../src/features/api/v1/internal.rs | 27 +++---------------- .../src/features/api/v1/preview_auth.rs | 12 ++++----- .../src/features/api/v1/projects.rs | 23 ++-------------- .../src/features/api/v1/regions.rs | 4 +-- apps/control-api/src/features/api/v1/teams.rs | 23 ++-------------- apps/control-api/src/infra/http/mod.rs | 24 +++++++++++++++++ 7 files changed, 42 insertions(+), 98 deletions(-) diff --git a/apps/control-api/src/features/api/v1/admin.rs b/apps/control-api/src/features/api/v1/admin.rs index 981bf20..035342e 100644 --- a/apps/control-api/src/features/api/v1/admin.rs +++ b/apps/control-api/src/features/api/v1/admin.rs @@ -1,3 +1,5 @@ +use crate::infra::http::{cache, database}; + pub mod announcements; pub mod audit_events; pub mod batch; @@ -28,10 +30,7 @@ use axum::{ }; use serde_json::json; -use crate::{ - infra::error::{AppError, ok_response}, - state::ControlApiState, -}; +use crate::{infra::error::ok_response, state::ControlApiState}; pub fn router() -> Router { Router::new() @@ -203,26 +202,6 @@ pub fn router() -> Router { .route("/nodes/{node_id}/rotate-token", post(nodes::rotate_token)) } -pub(crate) fn database<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a sea_orm::DatabaseConnection, AppError> { - state.try_database().ok_or_else(|| AppError::Internal { - op, - message: "database not available".to_owned(), - }) -} - -pub(crate) fn cache<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a grass_cache::CacheStore, AppError> { - state.try_cache().ok_or_else(|| AppError::Internal { - op, - message: "cache not available".to_owned(), - }) -} - async fn status() -> impl IntoResponse { ok_response(json!({ "service": "Grass Worker Control API", diff --git a/apps/control-api/src/features/api/v1/internal.rs b/apps/control-api/src/features/api/v1/internal.rs index f692503..a9d530b 100644 --- a/apps/control-api/src/features/api/v1/internal.rs +++ b/apps/control-api/src/features/api/v1/internal.rs @@ -1,3 +1,5 @@ +use crate::infra::http::{cache, database}; + pub mod deployments; pub mod log_stream; pub mod nodes; @@ -8,10 +10,7 @@ use axum::{ routing::{get, post, put}, }; -use crate::{ - infra::{error::AppError, http::middlewares::node_auth}, - state::ControlApiState, -}; +use crate::{infra::http::middlewares::node_auth, state::ControlApiState}; pub fn router(state: ControlApiState) -> Router { Router::new() @@ -76,26 +75,6 @@ pub fn router(state: ControlApiState) -> Router { )) } -pub(crate) fn database<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a sea_orm::DatabaseConnection, AppError> { - state.try_database().ok_or_else(|| AppError::Internal { - op, - message: "database not available".to_owned(), - }) -} - -pub(crate) fn cache<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a grass_cache::CacheStore, AppError> { - state.try_cache().ok_or_else(|| AppError::Internal { - op, - message: "cache not available".to_owned(), - }) -} - pub(crate) fn storage(state: &ControlApiState) -> crate::infra::storage::StorageManager { state.storage.clone() } diff --git a/apps/control-api/src/features/api/v1/preview_auth.rs b/apps/control-api/src/features/api/v1/preview_auth.rs index f80bd2e..8a13491 100644 --- a/apps/control-api/src/features/api/v1/preview_auth.rs +++ b/apps/control-api/src/features/api/v1/preview_auth.rs @@ -451,8 +451,8 @@ pub async fn start( Json(body): Json, ) -> Result { const OP: &str = "internal.serve.preview_authorize"; - let db = super::internal::database(&state, OP)?; - let cache = super::internal::cache(&state, OP)?; + let db = crate::infra::http::database(&state, OP)?; + let cache = crate::infra::http::cache(&state, OP)?; let binding = resolve_preview_binding(db, &body.host, OP).await?; let return_to = validate_return_to(&body.return_to).map_err(|error| AppError::Validation { op: OP, @@ -574,8 +574,8 @@ pub async fn exchange( Json(body): Json, ) -> Result { const OP: &str = "internal.serve.preview_exchange"; - let db = super::internal::database(&state, OP)?; - let cache = super::internal::cache(&state, OP)?; + let db = crate::infra::http::database(&state, OP)?; + let cache = crate::infra::http::cache(&state, OP)?; let host = grass_validator::normalize_host(&body.host).map_err(|error| AppError::Validation { op: OP, @@ -648,8 +648,8 @@ pub async fn verify( Json(body): Json, ) -> Result { const OP: &str = "internal.serve.preview_verify"; - let db = super::internal::database(&state, OP)?; - let cache = super::internal::cache(&state, OP)?; + let db = crate::infra::http::database(&state, OP)?; + let cache = crate::infra::http::cache(&state, OP)?; let host = grass_validator::normalize_host(&body.host).map_err(|error| AppError::Validation { op: OP, diff --git a/apps/control-api/src/features/api/v1/projects.rs b/apps/control-api/src/features/api/v1/projects.rs index 1bd42ea..e15698a 100644 --- a/apps/control-api/src/features/api/v1/projects.rs +++ b/apps/control-api/src/features/api/v1/projects.rs @@ -1,3 +1,5 @@ +use crate::infra::http::{cache, database}; + pub mod create; pub mod deployments; pub mod detail; @@ -11,7 +13,6 @@ use axum::{ Router, routing::{get, post}, }; -use sea_orm::DatabaseConnection; use uuid::Uuid; use crate::infra::http::timestamps::ts; @@ -239,26 +240,6 @@ pub(crate) async fn project_access( }) } -pub(crate) fn database<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a DatabaseConnection, AppError> { - state.try_database().ok_or_else(|| AppError::Internal { - op, - message: "database not available".to_owned(), - }) -} - -pub(crate) fn cache<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a grass_cache::CacheStore, AppError> { - state.try_cache().ok_or_else(|| AppError::Internal { - op, - message: "cache not available".to_owned(), - }) -} - pub(crate) fn project_view(project: &project::Model) -> serde_json::Value { serde_json::json!({ "id": project.id, diff --git a/apps/control-api/src/features/api/v1/regions.rs b/apps/control-api/src/features/api/v1/regions.rs index 28962ba..e48e2ef 100644 --- a/apps/control-api/src/features/api/v1/regions.rs +++ b/apps/control-api/src/features/api/v1/regions.rs @@ -2,7 +2,7 @@ use crate::{ domain::regions, infra::{ error::{AppError, ok_response}, - http::extractors::Session, + http::{database, extractors::Session}, }, state::ControlApiState, }; @@ -13,7 +13,7 @@ pub async fn list( _session: Session, ) -> Result { const OP: &str = "regions.list"; - let db = super::admin::database(&state, OP)?; + let db = database(&state, OP)?; Ok(ok_response(regions::available(db).await.map_err( |source| AppError::Infrastructure { op: OP, source }, )?)) diff --git a/apps/control-api/src/features/api/v1/teams.rs b/apps/control-api/src/features/api/v1/teams.rs index 61bf03c..530d0d4 100644 --- a/apps/control-api/src/features/api/v1/teams.rs +++ b/apps/control-api/src/features/api/v1/teams.rs @@ -1,3 +1,5 @@ +use crate::infra::http::{cache, database}; + pub mod audit; pub mod create; pub mod detail; @@ -13,7 +15,6 @@ use axum::{ Router, routing::{get, patch, post}, }; -use sea_orm::DatabaseConnection; use crate::{infra::error::AppError, state::ControlApiState}; @@ -62,26 +63,6 @@ pub fn router() -> Router { .route("/teams/{team_id}/invitations", post(invitations::create)) } -pub(crate) fn database<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a DatabaseConnection, AppError> { - state.try_database().ok_or_else(|| AppError::Internal { - op, - message: "database not available".to_owned(), - }) -} - -pub(crate) fn cache<'a>( - state: &'a ControlApiState, - op: &'static str, -) -> Result<&'a grass_cache::CacheStore, AppError> { - state.try_cache().ok_or_else(|| AppError::Internal { - op, - message: "cache not available".to_owned(), - }) -} - pub(crate) fn validate_required(value: &str, op: &'static str, name: &str) -> Result<(), AppError> { if value.trim().is_empty() { return Err(AppError::Validation { diff --git a/apps/control-api/src/infra/http/mod.rs b/apps/control-api/src/infra/http/mod.rs index 89e21b0..71fcb7b 100644 --- a/apps/control-api/src/infra/http/mod.rs +++ b/apps/control-api/src/infra/http/mod.rs @@ -1,3 +1,27 @@ +use crate::{infra::error::AppError, state::ControlApiState}; + pub mod extractors; pub mod middlewares; pub mod timestamps; + +/// Retrieves the database for an HTTP operation, preserving its error context. +pub(crate) fn database<'a>( + state: &'a ControlApiState, + op: &'static str, +) -> Result<&'a sea_orm::DatabaseConnection, AppError> { + state.try_database().ok_or_else(|| AppError::Internal { + op, + message: "database not available".to_owned(), + }) +} + +/// Retrieves the cache for an HTTP operation, preserving its error context. +pub(crate) fn cache<'a>( + state: &'a ControlApiState, + op: &'static str, +) -> Result<&'a grass_cache::CacheStore, AppError> { + state.try_cache().ok_or_else(|| AppError::Internal { + op, + message: "cache not available".to_owned(), + }) +} From 10bc4fa634993140314cb957b19a78359a044fcb Mon Sep 17 00:00:00 2001 From: yuanzui-cf Date: Sat, 12 Sep 2026 17:08:23 +0800 Subject: [PATCH 3/6] refactor(storage): Share configuration parsing Refs #197 --- apps/console/src/features/admin/admin.api.ts | 39 +----- .../components/storage-settings-panel.tsx | 72 +++++----- .../setup/components/storage-step.tsx | 58 ++++---- apps/console/src/features/setup/setup.api.ts | 33 +---- .../src/features/storage/storage-form.ts | 67 +++++++++ .../src/domain/storage_settings.rs | 129 +++++++++++++++++- .../src/features/api/v1/admin/storage.rs | 81 ++--------- .../src/features/api/v1/setup/storage.rs | 109 +++++---------- docs/self-hosting.md | 3 + 9 files changed, 314 insertions(+), 277 deletions(-) create mode 100644 apps/console/src/features/storage/storage-form.ts diff --git a/apps/console/src/features/admin/admin.api.ts b/apps/console/src/features/admin/admin.api.ts index c33b639..9efb578 100644 --- a/apps/console/src/features/admin/admin.api.ts +++ b/apps/console/src/features/admin/admin.api.ts @@ -1,3 +1,4 @@ +import type { StorageConfiguration, StorageInput } from "@/features/storage/storage-form"; import type { BuildStatus, ReleaseStatus, @@ -602,41 +603,13 @@ export interface AdminSettings { restart_required_sections: Array<"server" | "redis" | "node_manager" | "migration" | "log">; } -export type AdminStorageBackend = "local" | "s3" | "minio" | "r2"; - -export interface AdminStorageConfiguration { - backend: AdminStorageBackend; - local_root: string; - endpoint: string; - region: string; - bucket: string; - prefix: string; - force_path_style: boolean; - allow_http: boolean; - credentials_configured: boolean; -} - -export interface AdminStorageInput { - backend: AdminStorageBackend; - local_root?: string; - endpoint?: string; - region?: string; - bucket?: string; - prefix?: string; - force_path_style?: boolean; - allow_http?: boolean; - access_key_id?: string; - secret_access_key?: string; - session_token?: string; -} - export type AdminStorageMigrationStatus = "pending" | "running" | "succeeded" | "failed"; export interface AdminStorageMigration { id: string; status: AdminStorageMigrationStatus; - source: AdminStorageConfiguration; - target: AdminStorageConfiguration; + source: StorageConfiguration; + target: StorageConfiguration; copied_objects: number; copied_bytes: number; total_objects: number | null; @@ -648,7 +621,7 @@ export interface AdminStorageMigration { } export interface AdminStorageState { - storage: AdminStorageConfiguration; + storage: StorageConfiguration; maintenance: boolean; migration: AdminStorageMigration | null; } @@ -1113,13 +1086,13 @@ export const adminApi = { getStorage: () => request("/api/v1/admin/storage"), - testStorage: (input: AdminStorageInput) => + testStorage: (input: StorageInput) => request<{ tested: true }>("/api/v1/admin/storage/test", { method: "POST", body: JSON.stringify(input), }), - createStorageMigration: (input: AdminStorageInput) => + createStorageMigration: (input: StorageInput) => request<{ migration: AdminStorageMigration }>("/api/v1/admin/storage/migrations", { method: "POST", body: JSON.stringify(input), diff --git a/apps/console/src/features/admin/components/storage-settings-panel.tsx b/apps/console/src/features/admin/components/storage-settings-panel.tsx index b8ef076..764b72b 100644 --- a/apps/console/src/features/admin/components/storage-settings-panel.tsx +++ b/apps/console/src/features/admin/components/storage-settings-panel.tsx @@ -1,3 +1,10 @@ +import { + storageDefaults, + storageInput, + type StorageBackend, + type StorageInput, + type StorageConfiguration, +} from "@/features/storage/storage-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2Icon, @@ -42,28 +49,17 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; -import { - adminApi, - type AdminStorageBackend, - type AdminStorageConfiguration, - type AdminStorageInput, - type AdminStorageMigration, - type AdminStorageState, -} from "../admin.api"; +import { adminApi, type AdminStorageMigration, type AdminStorageState } from "../admin.api"; const ACTIVE_MIGRATION_STATUSES = new Set(["pending", "running"]); -const PROVIDER_LABELS: Record = { +const PROVIDER_LABELS: Record = { local: "Local filesystem", s3: "S3-compatible", minio: "MinIO", r2: "Cloudflare R2", }; -function defaultRegion(backend: AdminStorageBackend) { - return backend === "r2" ? "auto" : "us-east-1"; -} - function migrationProgress(migration: AdminStorageMigration) { if (migration.total_objects === 0) { return migration.status === "succeeded" ? 100 : 0; @@ -72,7 +68,7 @@ function migrationProgress(migration: AdminStorageMigration) { return Math.min(100, Math.round((migration.copied_objects / migration.total_objects) * 100)); } -function CurrentStorage({ storage }: { storage: AdminStorageConfiguration }) { +function CurrentStorage({ storage }: { storage: StorageConfiguration }) { const remote = storage.backend !== "local"; return (
@@ -170,14 +166,14 @@ function MigrationStatus({ migration }: { migration: AdminStorageMigration }) { export function StorageSettingsPanel() { const queryClient = useQueryClient(); - const [backend, setBackend] = useState("local"); + const [backend, setBackend] = useState("local"); const [localRoot, setLocalRoot] = useState(null); const [endpoint, setEndpoint] = useState(""); - const [region, setRegion] = useState(defaultRegion("local")); + const [region, setRegion] = useState(storageDefaults("local").region); const [bucket, setBucket] = useState(""); const [prefix, setPrefix] = useState(""); - const [forcePathStyle, setForcePathStyle] = useState(false); - const [allowHttp, setAllowHttp] = useState(false); + const [forcePathStyle, setForcePathStyle] = useState(storageDefaults("local").forcePathStyle); + const [allowHttp, setAllowHttp] = useState(storageDefaults("local").allowHttp); const [accessKeyId, setAccessKeyId] = useState(""); const [secretAccessKey, setSecretAccessKey] = useState(""); const [sessionToken, setSessionToken] = useState(""); @@ -198,24 +194,20 @@ export function StorageSettingsPanel() { const remote = backend !== "local"; const maintenance = storageQuery.data?.maintenance ?? false; - const input = (): AdminStorageInput => { - if (backend === "local") { - return { backend, local_root: currentRoot.trim() }; - } - return { + const input = (): StorageInput => + storageInput({ backend, - local_root: currentRoot.trim(), - endpoint: endpoint.trim(), - region: region.trim(), - bucket: bucket.trim(), - prefix: prefix.trim().replace(/^\/+|\/+$/g, ""), - force_path_style: forcePathStyle, - allow_http: allowHttp, - ...(accessKeyId.trim() && { access_key_id: accessKeyId.trim() }), - ...(secretAccessKey.trim() && { secret_access_key: secretAccessKey.trim() }), - ...(sessionToken.trim() && { session_token: sessionToken.trim() }), - }; - }; + localRoot: currentRoot, + endpoint, + region, + bucket, + prefix, + forcePathStyle, + allowHttp, + accessKeyId, + secretAccessKey, + sessionToken, + }); const testMutation = useMutation({ mutationFn: () => adminApi.testStorage(input()), @@ -238,12 +230,12 @@ export function StorageSettingsPanel() { setConnectionVerified(false); }; - const changeBackend = (value: AdminStorageBackend) => { + const changeBackend = (value: StorageBackend) => { change(() => { setBackend(value); - setRegion(defaultRegion(value)); - setForcePathStyle(value === "minio"); - setAllowHttp(value === "minio"); + setRegion(storageDefaults(value).region); + setForcePathStyle(storageDefaults(value).forcePathStyle); + setAllowHttp(storageDefaults(value).allowHttp); }); }; @@ -275,7 +267,7 @@ export function StorageSettingsPanel() { Target backend