feat(enabled): scripts/routes enabled column + declarative data path
Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers already carried `enabled`; this adds it to scripts and routes and threads it through the declarative project tool. Runtime honoring (disabled route 404 / script non-invocable / dispatcher fire-time re-check) is the next commit — this change only stores and reconciles the flag. - Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts + routes. Default true ⇒ no behavior change on migrate. - `Script`/`Route` (shared) gain `enabled` (serde default true via the new `picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it. - Apply diff treats `enabled` as a declarative field (omitted ⇒ active): `script_update_reason` + `diff_routes` detect a toggle as an Update, and the create/update/insert paths persist it. The bound-plan `state_token` re-includes script/route `enabled` (removed in the earlier review fix precisely because the diff didn't key on it yet — now it does). - CLI manifest model + `build_bundle` + `pull` round-trip `enabled` (serialized only when false; omitted ⇒ active). `pic init` scaffold and the interactive script/route create paths default active. Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) + cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
|
||||
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
|
||||
-- this adds the same toggle to scripts and routes. A disabled script is not
|
||||
-- invocable; a disabled route 404s (indistinguishable from absent). Default
|
||||
-- TRUE so every existing row stays active — no behavior change on migrate.
|
||||
|
||||
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -246,6 +246,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
// Scripts are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
@@ -345,6 +347,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: input.sandbox,
|
||||
kind: input.kind,
|
||||
enabled: None,
|
||||
imports: imports_for_patch,
|
||||
},
|
||||
)
|
||||
@@ -476,10 +479,9 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_)
|
||||
| Self::BadRequest(_)
|
||||
| Self::Invalid(_)
|
||||
| Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ async fn seed_into(
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
imports: Vec::new(),
|
||||
})
|
||||
.await?;
|
||||
@@ -85,6 +86,7 @@ async fn seed_into(
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ pub struct BundleScript {
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not
|
||||
/// leave-as-is): a script absent the key is `enabled = true`.
|
||||
#[serde(default = "picloud_shared::default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -107,6 +111,9 @@ pub struct BundleRoute {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||||
#[serde(default = "picloud_shared::default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
|
||||
@@ -495,6 +502,7 @@ impl ApplyService {
|
||||
timeout_seconds: bs.timeout_seconds,
|
||||
memory_limit_mb: bs.memory_limit_mb,
|
||||
sandbox: bs.sandbox,
|
||||
enabled: bs.enabled,
|
||||
imports: self.script_imports(bs)?,
|
||||
};
|
||||
let created = insert_script_tx(&mut tx, &new).await.map_err(map_repo)?;
|
||||
@@ -515,6 +523,8 @@ impl ApplyService {
|
||||
memory_limit_mb: bs.memory_limit_mb,
|
||||
sandbox: bs.sandbox,
|
||||
kind: Some(bs.kind),
|
||||
// Declarative: always reconcile to the manifest's value.
|
||||
enabled: Some(bs.enabled),
|
||||
imports: Some(self.script_imports(bs)?),
|
||||
};
|
||||
update_script_tx(&mut tx, cur.id, &patch)
|
||||
@@ -1080,6 +1090,14 @@ fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String>
|
||||
if cur.kind != desired.kind {
|
||||
return Some("kind changed".into());
|
||||
}
|
||||
// Declarative (default true): always reconcile to the manifest's value.
|
||||
if cur.enabled != desired.enabled {
|
||||
return Some(if desired.enabled {
|
||||
"enabled".into()
|
||||
} else {
|
||||
"disabled".into()
|
||||
});
|
||||
}
|
||||
// Sparse like the other optional fields: an omitted (`None`) description
|
||||
// means "leave as-is", so only an explicitly-set value that differs
|
||||
// forces an update. (Clearing a description is done in the dashboard.)
|
||||
@@ -1161,6 +1179,7 @@ fn diff_routes(
|
||||
if cur_script != Some(r.script.as_str())
|
||||
|| cur.dispatch_mode != r.dispatch_mode
|
||||
|| cur.host_param_name != r.host_param_name
|
||||
|| cur.enabled != r.enabled
|
||||
{
|
||||
out.push(ResourceChange {
|
||||
op: Op::Update,
|
||||
@@ -1527,6 +1546,7 @@ async fn insert_bundle_route(
|
||||
path: br.path.clone(),
|
||||
method: br.method.clone(),
|
||||
dispatch_mode: br.dispatch_mode,
|
||||
enabled: br.enabled,
|
||||
};
|
||||
insert_route_tx(tx, &new).await.map_err(map_repo)?;
|
||||
Ok(())
|
||||
@@ -1622,11 +1642,16 @@ pub fn state_token(current: &CurrentState) -> String {
|
||||
+ current.secret_names.len(),
|
||||
);
|
||||
for s in ¤t.scripts {
|
||||
parts.push(format!("s|{}|{}", s.name.to_lowercase(), s.version));
|
||||
parts.push(format!(
|
||||
"s|{}|{}|{}",
|
||||
s.name.to_lowercase(),
|
||||
s.version,
|
||||
s.enabled
|
||||
));
|
||||
}
|
||||
for r in ¤t.routes {
|
||||
parts.push(format!(
|
||||
"r|{}|{:?}|{:?}|{}",
|
||||
"r|{}|{:?}|{:?}|{}|{}",
|
||||
route_key(
|
||||
r.method.as_deref(),
|
||||
r.host_kind,
|
||||
@@ -1637,6 +1662,7 @@ pub fn state_token(current: &CurrentState) -> String {
|
||||
r.script_id,
|
||||
r.dispatch_mode,
|
||||
r.host_param_name.as_deref().unwrap_or(""),
|
||||
r.enabled,
|
||||
));
|
||||
}
|
||||
// Mirror exactly what the trigger diff keys on: `current_trigger_identity`
|
||||
@@ -1698,6 +1724,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
sandbox: ScriptSandbox::empty(),
|
||||
memory_limit_mb: 256,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
@@ -1712,6 +1739,7 @@ mod tests {
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,6 +1826,7 @@ mod tests {
|
||||
path: "/p".into(),
|
||||
method: Some("POST".into()),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let current = CurrentState {
|
||||
@@ -1817,6 +1846,7 @@ mod tests {
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/p".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
@@ -2047,6 +2077,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_is_declarative_and_diffed() {
|
||||
// A live-active script the manifest marks disabled → Update; the token
|
||||
// is sensitive to the flag so an out-of-band toggle is detectable.
|
||||
let cur = script("a", "let x = 1;"); // enabled: true
|
||||
let base = CurrentState {
|
||||
scripts: vec![cur.clone()],
|
||||
..CurrentState::default()
|
||||
};
|
||||
let mut disable = empty_bundle();
|
||||
let mut bs = bundle_script("a", "let x = 1;");
|
||||
bs.enabled = false;
|
||||
disable.scripts = vec![bs];
|
||||
assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update);
|
||||
|
||||
let mut flipped = cur;
|
||||
flipped.enabled = false;
|
||||
let toggled = CurrentState {
|
||||
scripts: vec![flipped],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(
|
||||
state_token(&base),
|
||||
state_token(&toggled),
|
||||
"token must flip on an enabled change"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_diff_create_noop_delete() {
|
||||
let s = script("h", "x");
|
||||
|
||||
@@ -313,6 +313,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
memory_limit_mb: 64,
|
||||
sandbox: ScriptSandbox::default(),
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ pub struct NewScript {
|
||||
/// Sandbox overrides; `None` means store an empty object (use
|
||||
/// platform defaults at exec time).
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// v1.1.3: literal-path `import "<name>"` declarations extracted
|
||||
/// from the source. The repo writes these into `script_imports`
|
||||
/// transactionally with the script row. Empty when validation
|
||||
@@ -176,6 +178,8 @@ pub struct ScriptPatch {
|
||||
/// rejects unsafe transitions (e.g. endpoint→module when routes
|
||||
/// or triggers reference the script).
|
||||
pub kind: Option<ScriptKind>,
|
||||
/// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it.
|
||||
pub enabled: Option<bool>,
|
||||
/// v1.1.3: when `source` is also `Some`, the repo replaces the
|
||||
/// `script_imports` edges for this script with these names.
|
||||
/// `None` keeps the existing edges untouched (a name/description
|
||||
@@ -203,7 +207,8 @@ impl PostgresScriptRepository {
|
||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||
/// one query.
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||
created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
@@ -393,8 +398,8 @@ pub(crate) async fn insert_script_tx(
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
@@ -405,6 +410,7 @@ pub(crate) async fn insert_script_tx(
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
@@ -441,6 +447,7 @@ pub(crate) async fn update_script_tx(
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
enabled = COALESCE($10, enabled), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
@@ -455,6 +462,7 @@ pub(crate) async fn update_script_tx(
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.bind(patch.enabled)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
@@ -505,6 +513,7 @@ struct ScriptRow {
|
||||
timeout_seconds: i32,
|
||||
memory_limit_mb: i32,
|
||||
sandbox: serde_json::Value,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -531,6 +540,7 @@ impl From<ScriptRow> for Script {
|
||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||
sandbox,
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
path: normalized_path,
|
||||
method: input.method,
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
@@ -625,6 +627,7 @@ mod tests {
|
||||
path: path.to_string(),
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NewRoute {
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -63,7 +65,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -74,7 +76,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
.bind(route_id)
|
||||
@@ -86,7 +88,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
@@ -101,7 +103,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
@@ -173,10 +175,10 @@ pub(crate) async fn insert_route_tx(
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
path_kind, path, method, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at",
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
@@ -187,6 +189,7 @@ pub(crate) async fn insert_route_tx(
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
@@ -231,6 +234,7 @@ struct RouteRow {
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -255,6 +259,7 @@ impl From<RouteRow> for Route {
|
||||
path: r.path,
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1347,6 +1347,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
sandbox: picloud_shared::ScriptSandbox::default(),
|
||||
memory_limit_mb: 256,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
}],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "hello".into(),
|
||||
@@ -132,6 +133,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
|
||||
@@ -61,6 +61,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
if let Some(sb) = &s.sandbox {
|
||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
||||
}
|
||||
obj.insert("enabled".into(), json!(s.enabled));
|
||||
scripts.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
path_kind: r.path_kind,
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
enabled: r.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -90,6 +91,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
} else {
|
||||
Some(s.sandbox)
|
||||
},
|
||||
enabled: s.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,13 @@ pub struct ManifestScript {
|
||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
|
||||
/// invocable). Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -102,6 +109,13 @@ pub struct ManifestRoute {
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
|
||||
/// Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
@@ -244,6 +258,11 @@ fn is_sync(mode: &DispatchMode) -> bool {
|
||||
*mode == DispatchMode::Sync
|
||||
}
|
||||
|
||||
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
|
||||
fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
@@ -268,6 +287,7 @@ mod tests {
|
||||
timeout_seconds: Some(10),
|
||||
memory_limit_mb: Some(256),
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
},
|
||||
ManifestScript {
|
||||
name: "lib".into(),
|
||||
@@ -280,6 +300,7 @@ mod tests {
|
||||
max_operations: Some(5_000_000),
|
||||
..ScriptSandbox::empty()
|
||||
}),
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
routes: vec![ManifestRoute {
|
||||
@@ -291,6 +312,7 @@ mod tests {
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
@@ -337,6 +359,16 @@ mod tests {
|
||||
);
|
||||
// Module kind IS non-default → emitted.
|
||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
||||
// `enabled` defaults true → omitted for the active script/route, but
|
||||
// the disabled `lib` script emits `enabled = false`.
|
||||
assert!(
|
||||
text.contains("enabled = false"),
|
||||
"a disabled entity must emit enabled:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("enabled = true"),
|
||||
"active entities must omit the default:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,6 +39,14 @@ pub mod users;
|
||||
pub mod validator;
|
||||
pub mod version;
|
||||
|
||||
/// serde `default` for `enabled`-style boolean fields that should default to
|
||||
/// `true` when absent from the wire (bool's own `Default` is `false`). Shared
|
||||
/// by `Script`/`Route`, the apply `Bundle` types, and the CLI manifest.
|
||||
#[must_use]
|
||||
pub fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub use app::{App, AppDomain, DomainShape};
|
||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError};
|
||||
|
||||
@@ -99,5 +99,11 @@ pub struct Route {
|
||||
#[serde(default)]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
|
||||
/// Three-state lifecycle (§4.3): `false` means the route is deployed but
|
||||
/// inert — it is dropped from the compiled match table, so a request 404s
|
||||
/// indistinguishably from an absent route. Defaults `true`.
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -122,6 +122,12 @@ pub struct Script {
|
||||
/// have to add it back when that's built.
|
||||
pub memory_limit_mb: u32,
|
||||
|
||||
/// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
|
||||
/// script is not invocable (route 404s, trigger doesn't fire) but stays
|
||||
/// as desired state (not pruned). Defaults `true`.
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user