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

@@ -520,6 +520,7 @@ pub fn format_pipeline_blocks(
"RUNNING" => ":arrows_counterclockwise:",
"FAILED" => ":x:",
"CANCELLED" => ":no_entry_sign:",
"AWAITING_APPROVAL" => ":shield:",
_ => ":radio_button:", // PENDING
};
@@ -546,6 +547,16 @@ pub fn format_pipeline_blocks(
_ => format!("Wait {dur_str}"),
}
}
"plan" => {
let env = stage.environment.as_deref().unwrap_or("unknown");
match stage.status.as_str() {
"SUCCEEDED" => format!("Plan approved for `{env}`"),
"RUNNING" => format!("Planning `{env}`"),
"AWAITING_APPROVAL" => format!("Awaiting plan approval for `{env}`"),
"FAILED" => format!("Plan failed for `{env}`"),
_ => format!("Plan `{env}`"),
}
}
_ => format!("Stage {}", stage.stage_id),
};
@@ -928,6 +939,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s2".into(),
@@ -942,6 +955,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s3".into(),
@@ -956,6 +971,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s4".into(),
@@ -970,6 +987,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
PipelineRunStageState {
stage_id: "s5".into(),
@@ -984,6 +1003,8 @@ mod tests {
error_message: None,
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
},
];
@@ -1024,6 +1045,8 @@ mod tests {
error_message: Some("OOM killed".into()),
wait_until: None,
release_ids: vec![],
approval_status: None,
auto_approve: None,
}];
let blocks = format_pipeline_blocks(&stages);

View File

@@ -130,8 +130,8 @@ pub struct DestinationState {
pub struct PipelineRunStageState {
pub stage_id: String,
pub depends_on: Vec<String>,
pub stage_type: String, // "deploy" or "wait"
pub status: String, // "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"
pub stage_type: String, // "deploy", "wait", or "plan"
pub status: String, // "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AWAITING_APPROVAL"
pub environment: Option<String>,
pub duration_seconds: Option<i64>,
pub queued_at: Option<String>,
@@ -141,6 +141,10 @@ pub struct PipelineRunStageState {
pub wait_until: Option<String>,
#[serde(default)]
pub release_ids: Vec<String>,
#[serde(default)]
pub approval_status: Option<String>,
#[serde(default)]
pub auto_approve: Option<bool>,
}
/// Combined response from get_destination_states: destinations only.
@@ -302,6 +306,7 @@ pub struct PipelineStage {
pub enum PipelineStageConfig {
Deploy { environment: String },
Wait { duration_seconds: i64 },
Plan { environment: String, auto_approve: bool },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -628,6 +633,43 @@ pub trait ForestPlatform: Send + Sync {
release_intent_id: &str,
target_environment: &str,
) -> Result<ApprovalState, PlatformError>;
async fn approve_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<(), PlatformError>;
async fn reject_plan_stage(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
reason: Option<&str>,
) -> Result<(), PlatformError>;
async fn get_plan_output(
&self,
access_token: &str,
release_intent_id: &str,
stage_id: &str,
) -> Result<PlanOutput, PlatformError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanOutput {
pub plan_output: String,
pub status: String,
pub outputs: Vec<PlanDestinationOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanDestinationOutput {
pub destination_id: String,
pub destination_name: String,
pub plan_output: String,
pub status: String,
}
#[cfg(test)]

View File

@@ -654,13 +654,28 @@ pub struct GetPlanOutputRequest {
#[prost(string, tag="2")]
pub stage_id: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetPlanOutputResponse {
/// deprecated: use outputs
#[prost(string, tag="1")]
pub plan_output: ::prost::alloc::string::String,
/// RUNNING, AWAITING_APPROVAL, APPROVED, REJECTED
#[prost(string, tag="2")]
pub status: ::prost::alloc::string::String,
#[prost(message, repeated, tag="3")]
pub outputs: ::prost::alloc::vec::Vec<PlanDestinationOutput>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct PlanDestinationOutput {
#[prost(string, tag="1")]
pub destination_id: ::prost::alloc::string::String,
#[prost(string, tag="2")]
pub destination_name: ::prost::alloc::string::String,
#[prost(string, tag="3")]
pub plan_output: ::prost::alloc::string::String,
/// SUCCEEDED, FAILED, RUNNING, etc.
#[prost(string, tag="4")]
pub status: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct Source {
@@ -2813,7 +2828,7 @@ pub struct PolicyEvaluation {
#[prost(string, tag="4")]
pub reason: ::prost::alloc::string::String,
#[prost(message, optional, tag="10")]
pub approval_state: ::core::option::Option<ExternalApprovalState>,
pub external_approval_state: ::core::option::Option<ExternalApprovalState>,
}
// ── CRUD messages ───────────────────────────────────────────────────
@@ -3404,6 +3419,9 @@ pub struct WorkAssignment {
/// Full destination configuration including metadata.
#[prost(message, optional, tag="6")]
pub destination: ::core::option::Option<DestinationInfo>,
/// Execution mode. Defaults to DEPLOY if unset.
#[prost(enumeration="ReleaseMode", tag="7")]
pub mode: i32,
}
/// Destination configuration sent with the work assignment.
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -3526,10 +3544,47 @@ pub struct CompleteReleaseRequest {
/// Error description when outcome is FAILURE.
#[prost(string, tag="3")]
pub error_message: ::prost::alloc::string::String,
/// Plan output text when mode was "plan" and outcome is SUCCESS.
/// Stored in release_states.plan_output for UI review.
#[prost(string, optional, tag="4")]
pub plan_output: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct CompleteReleaseResponse {
}
/// Execution mode for a work assignment.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum ReleaseMode {
Unspecified = 0,
/// Normal deployment execution.
Deploy = 1,
/// Dry-run / plan only (e.g. terraform plan). Runner should capture
/// plan output and include it in CompleteRelease.plan_output.
Plan = 2,
}
impl ReleaseMode {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Unspecified => "RELEASE_MODE_UNSPECIFIED",
Self::Deploy => "RELEASE_MODE_DEPLOY",
Self::Plan => "RELEASE_MODE_PLAN",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"RELEASE_MODE_UNSPECIFIED" => Some(Self::Unspecified),
"RELEASE_MODE_DEPLOY" => Some(Self::Deploy),
"RELEASE_MODE_PLAN" => Some(Self::Plan),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum ReleaseOutcome {

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 {