feat: add plan step

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-15 22:38:18 +01:00
parent 7eb6ae7cbb
commit 04e452ecc3
71 changed files with 1059 additions and 319 deletions

View File

@@ -6,7 +6,7 @@ use forage_core::platform::{
ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination,
ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation,
OrgMember, PipelineStage, PipelineStageConfig, PlatformError, Policy, PolicyConfig,
OrgMember, PipelineStage, PipelineStageConfig, PlanOutput, PlatformError, Policy, PolicyConfig,
PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
UpdateTriggerInput,
};
@@ -583,8 +583,8 @@ fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
}
Some(forage_grpc::pipeline_stage::Config::Plan(_)) => {
PipelineStageConfig::Deploy { environment: String::new() }
Some(forage_grpc::pipeline_stage::Config::Plan(p)) => {
PipelineStageConfig::Plan { environment: p.environment, auto_approve: p.auto_approve }
}
None => PipelineStageConfig::Deploy { environment: String::new() },
};
@@ -603,6 +603,7 @@ fn convert_pipeline_stage_state(
let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) {
Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy",
Ok(forage_grpc::PipelineRunStageType::Wait) => "wait",
Ok(forage_grpc::PipelineRunStageType::Plan) => "plan",
_ => "unknown",
};
let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) {
@@ -611,6 +612,7 @@ fn convert_pipeline_stage_state(
Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED",
Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED",
Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED",
Ok(forage_grpc::PipelineRunStageStatus::AwaitingApproval) => "AWAITING_APPROVAL",
_ => "PENDING",
};
forage_core::platform::PipelineRunStageState {
@@ -626,6 +628,8 @@ fn convert_pipeline_stage_state(
error_message: s.error_message,
wait_until: s.wait_until,
release_ids: s.release_ids,
approval_status: s.approval_status,
auto_approve: s.auto_approve,
}
}
@@ -663,6 +667,12 @@ fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec<forage_grpc::Pipeline
duration_seconds: *duration_seconds,
})
}
PipelineStageConfig::Plan { environment, auto_approve } => {
forage_grpc::pipeline_stage::Config::Plan(forage_grpc::PlanStageConfig {
environment: environment.clone(),
auto_approve: *auto_approve,
})
}
}),
})
.collect()
@@ -1872,6 +1882,81 @@ impl ForestPlatform for GrpcForestClient {
.map_err(map_platform_status)?;
Ok(convert_approval_state(resp.into_inner().state))
}
async fn approve_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ApprovePlanStageRequest {
release_intent_id: release_intent_id.into(),
stage_id: stage_id.into(),
},
)?;
self.release_client()
.approve_plan_stage(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn reject_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
reason: Option<&str>,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::RejectPlanStageRequest {
release_intent_id: release_intent_id.into(),
stage_id: stage_id.into(),
reason: reason.map(|s| s.into()),
},
)?;
self.release_client()
.reject_plan_stage(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn get_plan_output(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<PlanOutput, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::GetPlanOutputRequest {
release_intent_id: release_intent_id.into(),
stage_id: stage_id.into(),
},
)?;
let resp = self
.release_client()
.get_plan_output(req)
.await
.map_err(map_platform_status)?;
let inner = resp.into_inner();
Ok(PlanOutput {
plan_output: inner.plan_output,
status: inner.status,
outputs: inner.outputs.into_iter().map(|o| {
forage_core::platform::PlanDestinationOutput {
destination_id: o.destination_id,
destination_name: o.destination_name,
plan_output: o.plan_output,
status: o.status,
}
}).collect(),
})
}
}
fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluation {
@@ -1881,7 +1966,7 @@ fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluati
3 => "approval",
_ => "unknown",
};
let approval_state = e.approval_state.map(|s| convert_approval_state(Some(s)));
let approval_state = e.external_approval_state.map(|s| convert_approval_state(Some(s)));
PolicyEvaluation {
policy_name: e.policy_name,
policy_type: policy_type.into(),

View File

@@ -11,6 +11,7 @@ mod templates;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use forage_core::session::{FileSessionStore, SessionStore};
use forage_db::PgSessionStore;
@@ -54,8 +55,8 @@ fn init_telemetry() {
)
.build();
let otel_layer = tracing_opentelemetry::layer()
.with_tracer(tracer_provider.tracer("forage-server"));
let otel_layer =
tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("forage-server"));
tracing_subscriber::registry()
.with(env_filter)
@@ -119,7 +120,10 @@ async fn main() -> anyhow::Result<()> {
let mut mad = notmad::Mad::builder();
// Session store + integration store: PostgreSQL if DATABASE_URL is set
let (sessions, integration_store): (Arc<dyn SessionStore>, Option<Arc<dyn forage_core::integrations::IntegrationStore>>);
let (sessions, integration_store): (
Arc<dyn SessionStore>,
Option<Arc<dyn forage_core::integrations::IntegrationStore>>,
);
if let Ok(database_url) = std::env::var("DATABASE_URL") {
tracing::info!("using PostgreSQL session store");
@@ -129,12 +133,16 @@ async fn main() -> anyhow::Result<()> {
let pg_store = Arc::new(PgSessionStore::new(pool.clone()));
// Integration store (uses same pool)
let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY")
.unwrap_or_else(|_| {
tracing::warn!("INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)");
"forage-dev-key-not-for-production!!".to_string()
});
let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(pool, encryption_key.into_bytes()));
let encryption_key = std::env::var("INTEGRATION_ENCRYPTION_KEY").unwrap_or_else(|_| {
tracing::warn!(
"INTEGRATION_ENCRYPTION_KEY not set — using default key (not safe for production)"
);
"forage-dev-key-not-for-production!!".to_string()
});
let pg_integrations = Arc::new(forage_db::PgIntegrationStore::new(
pool,
encryption_key.into_bytes(),
));
// Session reaper component
mad.add(session_reaper::PgSessionReaper {
@@ -143,11 +151,15 @@ async fn main() -> anyhow::Result<()> {
});
sessions = pg_store;
integration_store = Some(pg_integrations as Arc<dyn forage_core::integrations::IntegrationStore>);
integration_store =
Some(pg_integrations as Arc<dyn forage_core::integrations::IntegrationStore>);
} else {
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)");
let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
tracing::info!(
"using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)"
);
let file_store =
Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
// File session reaper component
mad.add(session_reaper::FileSessionReaper {
@@ -159,8 +171,13 @@ async fn main() -> anyhow::Result<()> {
};
let forest_client = Arc::new(forest_client);
let mut state = AppState::new(template_engine, forest_client.clone(), forest_client.clone(), sessions)
.with_grpc_client(forest_client.clone());
let mut state = AppState::new(
template_engine,
forest_client.clone(),
forest_client.clone(),
sessions,
)
.with_grpc_client(forest_client.clone());
// Slack OAuth config (optional, enables "Add to Slack" button)
if let (Ok(client_id), Ok(client_secret)) = (
@@ -220,7 +237,9 @@ async fn main() -> anyhow::Result<()> {
});
} else {
// Fallback: direct dispatch (no durability)
tracing::warn!("NATS_URL not set — using direct notification dispatch (no durability)");
tracing::warn!(
"NATS_URL not set — using direct notification dispatch (no durability)"
);
mad.add(notification_worker::NotificationListener {
grpc: forest_client,
store: store.clone(),
@@ -234,12 +253,11 @@ async fn main() -> anyhow::Result<()> {
}
// HTTP server component
mad.add(serve_http::ServeHttp {
addr,
state,
});
mad.add(serve_http::ServeHttp { addr, state });
mad.run().await?;
mad.cancellation(Some(Duration::from_secs(10)))
.run()
.await?;
Ok(())
}

View File

@@ -125,6 +125,18 @@ pub fn router() -> Router<AppState> {
post(delete_pipeline),
)
.route("/users/{username}", get(user_profile))
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approve",
post(approve_plan_stage_submit),
)
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/reject",
post(reject_plan_stage_submit),
)
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output",
get(get_plan_output_api),
)
.route(
"/api/orgs/{org}/projects/{project}/timeline",
get(timeline_api),
@@ -402,7 +414,12 @@ async fn fetch_notifications(
if let Some(run_stages) = intent_stages_by_artifact.get(aid) {
let sorted = topo_sort_run_stages(run_stages);
for rs in sorted {
let display_status = deploy_stage_display_status(rs, &matching_states);
let base_status = deploy_stage_display_status(rs, &matching_states);
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
"AWAITING_APPROVAL"
} else {
base_status
};
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
@@ -413,6 +430,7 @@ async fn fetch_notifications(
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
approval_status => rs.approval_status,
});
}
}
@@ -895,7 +913,7 @@ async fn artifact_detail(
));
}
let (artifact_result, projects, dest_states, release_intents, pipelines) = tokio::join!(
let (artifact_result, projects, dest_states, release_intents, pipelines, environments) = tokio::join!(
state
.platform_client
.get_artifact_by_slug(&session.access_token, &slug),
@@ -911,6 +929,9 @@ async fn artifact_detail(
state
.platform_client
.list_release_pipelines(&session.access_token, &org, &project),
state
.platform_client
.list_environments(&session.access_token, &org),
);
// Fetch artifact spec after we have the artifact_id (needs artifact_result first).
@@ -954,44 +975,62 @@ async fn artifact_detail(
.any(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
});
// Build pipeline stages from intent data.
// Build pipeline stages from the most recent release intent for this artifact.
let mut pipeline_stages: Vec<minijinja::Value> = Vec::new();
for ri in &release_intents {
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
let sorted = topo_sort_run_stages(&ri.stages);
for rs in sorted {
let display_status = deploy_stage_display_status(rs, &matching_states);
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
environment => rs.environment,
duration_seconds => rs.duration_seconds,
status => display_status,
started_at => rs.started_at,
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
});
}
let latest_intent = release_intents
.iter()
.filter(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
.max_by_key(|ri| &ri.created_at);
if let Some(ri) = latest_intent {
let sorted = topo_sort_run_stages(&ri.stages);
for rs in sorted {
let base_status = deploy_stage_display_status(rs, &matching_states);
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
"AWAITING_APPROVAL"
} else {
base_status
};
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
environment => rs.environment,
duration_seconds => rs.duration_seconds,
status => display_status,
started_at => rs.started_at,
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
approval_status => rs.approval_status,
});
}
}
let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;
// Fetch policy evaluations for active release intents.
let mut policy_evaluations: Vec<minijinja::Value> = Vec::new();
let mut release_intent_id_str = String::new();
struct PolicyEvalEntry {
policy_name: String,
policy_type: String,
passed: bool,
reason: String,
target_environment: String,
approval_state: Option<forage_core::platform::ApprovalState>,
}
let mut raw_evals: Vec<PolicyEvalEntry> = Vec::new();
let release_intent_id_str = latest_intent
.map(|ri| ri.release_intent_id.clone())
.unwrap_or_default();
let is_release_author = false;
for ri in &release_intents {
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
release_intent_id_str = ri.release_intent_id.clone();
// Collect unique environments from the pipeline stages.
if let Some(ri) = latest_intent {
{
let mut seen = std::collections::BTreeSet::new();
let environments: Vec<String> = ri
.stages
.iter()
.filter_map(|s| s.environment.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.filter(|e| seen.insert(e.clone()))
.collect();
for env in &environments {
@@ -1007,40 +1046,55 @@ async fn artifact_detail(
.await
{
for eval in evals {
let approval_state_ctx = eval.approval_state.as_ref().map(|s| {
let decisions: Vec<minijinja::Value> = s
.decisions
.iter()
.map(|d| {
context! {
username => d.username,
decision => d.decision,
comment => d.comment,
decided_at => d.decided_at,
}
})
.collect();
context! {
required_approvals => s.required_approvals,
current_approvals => s.current_approvals,
decisions => decisions,
}
});
policy_evaluations.push(context! {
policy_name => eval.policy_name,
policy_type => eval.policy_type,
passed => eval.passed,
reason => eval.reason,
target_environment => env,
approval_state => approval_state_ctx,
raw_evals.push(PolicyEvalEntry {
policy_name: eval.policy_name,
policy_type: eval.policy_type,
passed: eval.passed,
reason: eval.reason,
target_environment: env.clone(),
approval_state: eval.approval_state,
});
}
}
}
break; // Only one active intent per artifact.
}
}
raw_evals.sort_by(|a, b| a.policy_type.cmp(&b.policy_type).then(a.policy_name.cmp(&b.policy_name)));
let policy_evaluations: Vec<minijinja::Value> = raw_evals
.iter()
.map(|eval| {
let approval_state_ctx = eval.approval_state.as_ref().map(|s| {
let decisions: Vec<minijinja::Value> = s
.decisions
.iter()
.map(|d| {
context! {
username => d.username,
decision => d.decision,
comment => d.comment,
decided_at => d.decided_at,
}
})
.collect();
context! {
required_approvals => s.required_approvals,
current_approvals => s.current_approvals,
decisions => decisions,
}
});
context! {
policy_name => eval.policy_name,
policy_type => eval.policy_type,
passed => eval.passed,
reason => eval.reason,
target_environment => eval.target_environment,
approval_state => approval_state_ctx,
}
})
.collect();
let current_org_entry = orgs.iter().find(|o| o.name == org);
let is_admin = current_org_entry
.map(|o| o.role == "owner" || o.role == "admin")
@@ -1066,6 +1120,8 @@ async fn artifact_detail(
})
.collect();
let artifact_id_val = artifact.artifact_id.clone();
let html = state
.templates
.render(
@@ -1127,6 +1183,12 @@ async fn artifact_detail(
release_intent_id => &release_intent_id_str,
is_release_author => is_release_author,
is_admin => is_admin,
artifact_id => &artifact_id_val,
has_active_pipeline => has_pipeline,
environments => warn_default("list_environments", environments)
.iter()
.map(|e| context! { name => e.name })
.collect::<Vec<_>>(),
},
)
.map_err(|e| {
@@ -2102,6 +2164,10 @@ pub struct ApiPipelineStage {
pub wait_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
}
#[derive(Debug, Serialize)]
@@ -2287,6 +2353,14 @@ fn build_timeline_json(
} else {
None
};
// For plan stages, use AWAITING_APPROVAL as display status when appropriate
let display_status = if rs.stage_type == "plan"
&& rs.approval_status.as_deref() == Some("AWAITING_APPROVAL")
{
"AWAITING_APPROVAL".to_string()
} else {
display_status
};
stages.push(ApiPipelineStage {
id: rs.stage_id.clone(),
stage_type: rs.stage_type.clone(),
@@ -2298,6 +2372,8 @@ fn build_timeline_json(
error_message: rs.error_message.clone(),
wait_until: rs.wait_until.clone(),
blocked_by,
approval_status: rs.approval_status.clone(),
auto_approve: rs.auto_approve,
});
}
}
@@ -2411,7 +2487,10 @@ fn build_timeline_json(
let mut seen_deployed = false;
for raw in raw_releases {
let needs_action = raw.release.pipeline_stages.iter().any(|s| s.blocked_by.is_some());
let needs_action = raw.release.pipeline_stages.iter().any(|s| {
s.blocked_by.is_some()
|| (s.stage_type == "plan" && s.status == "AWAITING_APPROVAL")
});
if raw.has_dests || needs_action {
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
@@ -4758,3 +4837,155 @@ async fn reject_release_submit(
.into_response())
}
// ── Plan stage approve / reject / output ─────────────────────────────
#[derive(Deserialize)]
struct PlanStageForm {
csrf_token: String,
release_intent_id: String,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
redirect_to: Option<String>,
}
async fn approve_plan_stage_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Form(form): Form<PlanStageForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if form.csrf_token != session.csrf_token {
return Err(approval_error(
&state,
&headers,
StatusCode::FORBIDDEN,
"CSRF validation failed. Please try again.",
));
}
state
.platform_client
.approve_plan_stage(
&session.access_token,
&form.release_intent_id,
&stage_id,
)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotAuthenticated => {
axum::response::Redirect::to("/login").into_response()
}
other => approval_error(
&state,
&headers,
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{other}"),
),
})?;
if let Some(redirect) = &form.redirect_to {
Ok(Redirect::to(redirect).into_response())
} else {
Ok(Json(serde_json::json!({ "ok": true })).into_response())
}
}
async fn reject_plan_stage_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Form(form): Form<PlanStageForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if form.csrf_token != session.csrf_token {
return Err(approval_error(
&state,
&headers,
StatusCode::FORBIDDEN,
"CSRF validation failed. Please try again.",
));
}
let reason = form.reason.as_deref().and_then(|s| {
let t = s.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
});
state
.platform_client
.reject_plan_stage(
&session.access_token,
&form.release_intent_id,
&stage_id,
reason.as_deref(),
)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotAuthenticated => {
axum::response::Redirect::to("/login").into_response()
}
other => approval_error(
&state,
&headers,
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{other}"),
),
})?;
if let Some(redirect) = &form.redirect_to {
Ok(Redirect::to(redirect).into_response())
} else {
Ok(Json(serde_json::json!({ "ok": true })).into_response())
}
}
#[derive(Deserialize)]
struct PlanOutputQuery {
release_intent_id: String,
}
async fn get_plan_output_api(
State(state): State<AppState>,
session: Session,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Query(query): Query<PlanOutputQuery>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
let output = state
.platform_client
.get_plan_output(
&session.access_token,
&query.release_intent_id,
&stage_id,
)
.await
.map_err(|e| {
internal_error(&state, "get plan output", &e)
})?;
let outputs: Vec<serde_json::Value> = output.outputs.iter().map(|o| {
serde_json::json!({
"destination_id": o.destination_id,
"destination_name": o.destination_name,
"plan_output": o.plan_output,
"status": o.status,
})
}).collect();
Ok(Json(serde_json::json!({
"plan_output": output.plan_output,
"status": output.status,
"outputs": outputs,
}))
.into_response())
}

View File

@@ -1,5 +1,6 @@
use std::net::SocketAddr;
use anyhow::Context;
use notmad::{Component, ComponentInfo, MadError};
use tokio_util::sync::CancellationToken;
@@ -20,7 +21,7 @@ impl Component for ServeHttp {
let listener = tokio::net::TcpListener::bind(self.addr)
.await
.map_err(|e| MadError::Inner(e.into()))?;
.context("failed to listen on port")?;
tracing::info!("listening on {}", self.addr);
@@ -29,7 +30,7 @@ impl Component for ServeHttp {
cancellation_token.cancelled().await;
})
.await
.map_err(|e| MadError::Inner(e.into()))?;
.context("failed to run axum server")?;
Ok(())
}

View File

@@ -773,6 +773,38 @@ impl ForestPlatform for MockPlatformClient {
decisions: vec![],
})
}
async fn approve_plan_stage(
&self,
_access_token: &str,
_release_intent_id: &str,
_stage_id: &str,
) -> Result<(), PlatformError> {
Ok(())
}
async fn reject_plan_stage(
&self,
_access_token: &str,
_release_intent_id: &str,
_stage_id: &str,
_reason: Option<&str>,
) -> Result<(), PlatformError> {
Ok(())
}
async fn get_plan_output(
&self,
_access_token: &str,
_release_intent_id: &str,
_stage_id: &str,
) -> Result<forage_core::platform::PlanOutput, PlatformError> {
Ok(forage_core::platform::PlanOutput {
plan_output: String::new(),
status: "RUNNING".into(),
outputs: vec![],
})
}
}
pub(crate) fn make_templates() -> TemplateEngine {