feat(wfe): integrate workflow engine for up, seed, verify, bootstrap

Dispatch `sunbeam up`, `sunbeam seed`, `sunbeam verify`, and
`sunbeam bootstrap` through WFE workflows instead of monolithic
functions. Steps communicate via JSON workflow data and each
workflow is persisted in a per-context SQLite database.
This commit is contained in:
2026-04-05 18:21:59 +01:00
parent dce085cd0c
commit 9cd3c641da
38 changed files with 5355 additions and 181 deletions

View File

@@ -0,0 +1,71 @@
//! Bootstrap workflow definition — Gitea admin setup sequence.
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowDefinition;
use super::steps;
/// Build the bootstrap workflow definition.
///
/// Steps execute sequentially:
/// 1. Get admin password from K8s secret
/// 2. Wait for Gitea pod to be ready
/// 3. Set admin password
/// 4. Mark admin as private
/// 5. Create orgs (studio, internal)
/// 6. Configure OIDC auth source
/// 7. Print result
pub fn build() -> WorkflowDefinition {
WorkflowBuilder::<serde_json::Value>::new()
.start_with::<steps::GetAdminPassword>()
.name("get-admin-password")
.then::<steps::WaitForGiteaPod>()
.name("wait-for-gitea-pod")
.then::<steps::SetAdminPassword>()
.name("set-admin-password")
.then::<steps::MarkAdminPrivate>()
.name("mark-admin-private")
.then::<steps::CreateOrgs>()
.name("create-orgs")
.then::<steps::ConfigureOIDC>()
.name("configure-oidc")
.then::<steps::PrintBootstrapResult>()
.name("print-bootstrap-result")
.end_workflow()
.build("bootstrap", 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_returns_valid_definition() {
let def = build();
assert_eq!(def.id, "bootstrap");
assert_eq!(def.version, 1);
assert_eq!(def.steps.len(), 7);
}
#[test]
fn test_build_step_names() {
let def = build();
let names: Vec<Option<&str>> = def
.steps
.iter()
.map(|s| s.name.as_deref())
.collect();
assert_eq!(
names,
vec![
Some("get-admin-password"),
Some("wait-for-gitea-pod"),
Some("set-admin-password"),
Some("mark-admin-private"),
Some("create-orgs"),
Some("configure-oidc"),
Some("print-bootstrap-result"),
]
);
}
}

View File

@@ -0,0 +1,37 @@
//! Bootstrap workflow — Gitea admin setup, org creation, OIDC configuration.
pub mod definition;
pub mod steps;
use crate::output;
/// Register all bootstrap workflow steps and the workflow definition with a host.
pub async fn register(host: &wfe::WorkflowHost) {
host.register_step::<steps::GetAdminPassword>().await;
host.register_step::<steps::WaitForGiteaPod>().await;
host.register_step::<steps::SetAdminPassword>().await;
host.register_step::<steps::MarkAdminPrivate>().await;
host.register_step::<steps::CreateOrgs>().await;
host.register_step::<steps::ConfigureOIDC>().await;
host.register_step::<steps::PrintBootstrapResult>().await;
host.register_workflow_definition(definition::build()).await;
}
/// Print a summary of the completed bootstrap workflow.
pub fn print_summary(instance: &wfe_core::models::WorkflowInstance) {
output::step("Bootstrap workflow summary:");
for ep in &instance.execution_pointers {
let fallback = format!("step-{}", ep.step_id);
let name = ep.step_name.as_deref().unwrap_or(&fallback);
let status = format!("{:?}", ep.status);
let duration = match (ep.start_time, ep.end_time) {
(Some(start), Some(end)) => {
let d = end - start;
format!("{}ms", d.num_milliseconds())
}
_ => "-".to_string(),
};
output::ok(&format!(" {name:<40} {status:<12} {duration}"));
}
}

View File

@@ -0,0 +1,453 @@
//! Steps for the bootstrap workflow — Gitea admin setup, org creation, OIDC.
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::kube as k;
use crate::output::{ok, step, warn};
use crate::workflows::data::BootstrapData;
const GITEA_ADMIN_USER: &str = "gitea_admin";
const GITEA_ADMIN_EMAIL: &str = "gitea@local.domain";
fn load_data(ctx: &StepExecutionContext<'_>) -> wfe_core::Result<BootstrapData> {
serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))
}
fn step_err(msg: impl Into<String>) -> wfe_core::WfeError {
wfe_core::WfeError::StepExecution(msg.into())
}
// ── GetAdminPassword ───────────────────────────────────────────────────────
/// Retrieve the Gitea admin password from the K8s secret.
#[derive(Default)]
pub struct GetAdminPassword;
#[async_trait::async_trait]
impl StepBody for GetAdminPassword {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let step_ctx = data.ctx.as_ref()
.ok_or_else(|| step_err("missing __ctx in workflow data"))?;
k::set_context(&step_ctx.kube_context, &step_ctx.ssh_host);
let pass = k::kube_get_secret_field("devtools", "gitea-admin-credentials", "password")
.await
.unwrap_or_default();
if pass.is_empty() {
warn("gitea-admin-credentials password not found -- cannot bootstrap.");
return Err(step_err("gitea-admin-credentials password not found"));
}
let domain = k::get_domain().await
.map_err(|e| step_err(e.to_string()))?;
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({
"gitea_admin_pass": pass,
"domain": domain,
}));
Ok(result)
}
}
// ── WaitForGiteaPod ────────────────────────────────────────────────────────
/// Wait for a Running + Ready Gitea pod (up to 3 minutes).
#[derive(Default)]
pub struct WaitForGiteaPod;
#[async_trait::async_trait]
impl StepBody for WaitForGiteaPod {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
step("Waiting for Gitea pod...");
let client = k::get_client().await.map_err(|e| step_err(e.to_string()))?;
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
kube::Api::namespaced(client.clone(), "devtools");
for _ in 0..60 {
let lp = kube::api::ListParams::default().labels("app.kubernetes.io/name=gitea");
if let Ok(pod_list) = pods.list(&lp).await {
for pod in &pod_list.items {
let phase = pod.status.as_ref()
.and_then(|s| s.phase.as_deref())
.unwrap_or("");
if phase != "Running" {
continue;
}
let ready = pod.status.as_ref()
.and_then(|s| s.container_statuses.as_ref())
.and_then(|cs| cs.first())
.map(|c| c.ready)
.unwrap_or(false);
if ready {
let name = pod.metadata.name.as_deref().unwrap_or("").to_string();
if !name.is_empty() {
ok(&format!("Gitea pod ready: {name}"));
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "gitea_pod": name }));
return Ok(result);
}
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
warn("Gitea pod not ready after 3 min -- skipping bootstrap.");
Err(step_err("Gitea pod not ready after 3 minutes"))
}
}
// ── SetAdminPassword ───────────────────────────────────────────────────────
/// Set the Gitea admin password via CLI.
#[derive(Default)]
pub struct SetAdminPassword;
#[async_trait::async_trait]
impl StepBody for SetAdminPassword {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let pod = data.gitea_pod.as_deref()
.ok_or_else(|| step_err("gitea_pod not set"))?;
let password = data.gitea_admin_pass.as_deref()
.ok_or_else(|| step_err("gitea_admin_pass not set"))?;
let (code, output) = k::kube_exec(
"devtools",
pod,
&[
"gitea", "admin", "user", "change-password",
"--username", GITEA_ADMIN_USER,
"--password", password,
"--must-change-password=false",
],
Some("gitea"),
)
.await
.map_err(|e| step_err(e.to_string()))?;
if code == 0 || output.to_lowercase().contains("password") {
ok(&format!("Admin '{GITEA_ADMIN_USER}' password set."));
} else {
warn(&format!("change-password: {output}"));
}
Ok(ExecutionResult::next())
}
}
// ── MarkAdminPrivate ───────────────────────────────────────────────────────
/// Mark the admin account as private via API.
#[derive(Default)]
pub struct MarkAdminPrivate;
#[async_trait::async_trait]
impl StepBody for MarkAdminPrivate {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let pod = data.gitea_pod.as_deref()
.ok_or_else(|| step_err("gitea_pod not set"))?;
let password = data.gitea_admin_pass.as_deref()
.ok_or_else(|| step_err("gitea_admin_pass not set"))?;
let body = serde_json::json!({
"source_id": 0,
"login_name": GITEA_ADMIN_USER,
"email": GITEA_ADMIN_EMAIL,
"visibility": "private",
});
let result = gitea_api(
pod, "PATCH",
&format!("/admin/users/{GITEA_ADMIN_USER}"),
password,
Some(&body),
)
.await?;
if result.get("login").and_then(|v| v.as_str()) == Some(GITEA_ADMIN_USER) {
ok(&format!("Admin '{GITEA_ADMIN_USER}' marked as private."));
} else {
warn(&format!("Could not set admin visibility: {result}"));
}
Ok(ExecutionResult::next())
}
}
// ── CreateOrgs ─────────────────────────────────────────────────────────────
/// Create the studio and internal organizations.
#[derive(Default)]
pub struct CreateOrgs;
#[async_trait::async_trait]
impl StepBody for CreateOrgs {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let pod = data.gitea_pod.as_deref()
.ok_or_else(|| step_err("gitea_pod not set"))?;
let password = data.gitea_admin_pass.as_deref()
.ok_or_else(|| step_err("gitea_admin_pass not set"))?;
let orgs = [
("studio", "public", "Public source code"),
("internal", "private", "Internal tools and services"),
];
for (org_name, visibility, desc) in &orgs {
let body = serde_json::json!({
"username": org_name,
"visibility": visibility,
"description": desc,
});
let result = gitea_api(pod, "POST", "/orgs", password, Some(&body)).await?;
if result.get("id").is_some() {
ok(&format!("Created org '{org_name}'."));
} else if result
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase()
.contains("already")
{
ok(&format!("Org '{org_name}' already exists."));
} else {
let msg = result
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{result}"));
warn(&format!("Org '{org_name}': {msg}"));
}
}
Ok(ExecutionResult::next())
}
}
// ── ConfigureOIDC ──────────────────────────────────────────────────────────
/// Configure Hydra as the OIDC authentication source.
#[derive(Default)]
pub struct ConfigureOIDC;
#[async_trait::async_trait]
impl StepBody for ConfigureOIDC {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let pod = data.gitea_pod.as_deref()
.ok_or_else(|| step_err("gitea_pod not set"))?;
let (_, auth_list_output) = k::kube_exec(
"devtools", pod, &["gitea", "admin", "auth", "list"], Some("gitea"),
)
.await
.map_err(|e| step_err(e.to_string()))?;
let mut existing_id: Option<String> = None;
let mut exact_ok = false;
for line in auth_list_output.lines().skip(1) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 2 {
continue;
}
let src_id = parts[0].trim();
let src_name = parts[1].trim();
if src_name == "Sunbeam" {
exact_ok = true;
break;
}
let src_type = if parts.len() > 2 { parts[2].trim() } else { "" };
if src_name == "Sunbeam Auth"
|| (src_name.starts_with("Sunbeam") && src_type == "OAuth2")
{
existing_id = Some(src_id.to_string());
}
}
if exact_ok {
ok("OIDC auth source 'Sunbeam' already present.");
return Ok(ExecutionResult::next());
}
if let Some(eid) = existing_id {
let (code, stderr) = k::kube_exec(
"devtools",
pod,
&[
"gitea", "admin", "auth", "update-oauth",
"--id", &eid,
"--name", "Sunbeam",
],
Some("gitea"),
)
.await
.map_err(|e| step_err(e.to_string()))?;
if code == 0 {
ok(&format!("Renamed OIDC auth source (id={eid}) to 'Sunbeam'."));
} else {
warn(&format!("Rename failed: {stderr}"));
}
return Ok(ExecutionResult::next());
}
// Create new OIDC auth source
let oidc_id = k::kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await;
let oidc_secret = k::kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await;
match (oidc_id, oidc_secret) {
(Ok(oidc_id), Ok(oidc_sec)) => {
let discover_url =
"http://hydra-public.ory.svc.cluster.local:4444/.well-known/openid-configuration";
let (code, stderr) = k::kube_exec(
"devtools",
pod,
&[
"gitea", "admin", "auth", "add-oauth",
"--name", "Sunbeam",
"--provider", "openidConnect",
"--key", &oidc_id,
"--secret", &oidc_sec,
"--auto-discover-url", discover_url,
"--scopes", "openid",
"--scopes", "email",
"--scopes", "profile",
],
Some("gitea"),
)
.await
.map_err(|e| step_err(e.to_string()))?;
if code == 0 {
ok("OIDC auth source 'Sunbeam' configured.");
} else {
warn(&format!("OIDC auth source config failed: {stderr}"));
}
}
_ => {
warn("oidc-gitea secret not found -- OIDC auth source not configured.");
}
}
Ok(ExecutionResult::next())
}
}
// ── PrintBootstrapResult ───────────────────────────────────────────────────
/// Print the final bootstrap result.
#[derive(Default)]
pub struct PrintBootstrapResult;
#[async_trait::async_trait]
impl StepBody for PrintBootstrapResult {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let domain = data.domain.as_deref().unwrap_or("unknown");
ok(&format!(
"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / <from openbao>)"
));
Ok(ExecutionResult::next())
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
/// Call Gitea API via kubectl curl inside the pod.
async fn gitea_api(
pod: &str,
method: &str,
path: &str,
password: &str,
data: Option<&serde_json::Value>,
) -> wfe_core::Result<serde_json::Value> {
let url = format!("http://localhost:3000/api/v1{path}");
let auth = format!("{GITEA_ADMIN_USER}:{password}");
let mut args = vec![
"curl", "-s", "-X", method, &url, "-H", "Content-Type: application/json", "-u", &auth,
];
let data_str;
if let Some(d) = data {
data_str = serde_json::to_string(d)
.map_err(|e| step_err(e.to_string()))?;
args.push("-d");
args.push(&data_str);
}
let (_, stdout) = k::kube_exec("devtools", pod, &args, Some("gitea"))
.await
.map_err(|e| step_err(e.to_string()))?;
Ok(serde_json::from_str(&stdout).unwrap_or(serde_json::Value::Object(Default::default())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_admin_password_is_default() { let _ = GetAdminPassword::default(); }
#[test]
fn wait_for_gitea_pod_is_default() { let _ = WaitForGiteaPod::default(); }
#[test]
fn set_admin_password_is_default() { let _ = SetAdminPassword::default(); }
#[test]
fn mark_admin_private_is_default() { let _ = MarkAdminPrivate::default(); }
#[test]
fn create_orgs_is_default() { let _ = CreateOrgs::default(); }
#[test]
fn configure_oidc_is_default() { let _ = ConfigureOIDC::default(); }
#[test]
fn print_bootstrap_result_is_default() { let _ = PrintBootstrapResult::default(); }
#[test]
fn test_constants() {
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
assert_eq!(GITEA_ADMIN_EMAIL, "gitea@local.domain");
}
}

View File

@@ -0,0 +1,13 @@
//! Bootstrap workflow steps — Gitea admin setup, org creation, OIDC configuration.
mod bootstrap;
pub use bootstrap::{
GetAdminPassword,
WaitForGiteaPod,
SetAdminPassword,
MarkAdminPrivate,
CreateOrgs,
ConfigureOIDC,
PrintBootstrapResult,
};

416
src/workflows/cmd.rs Normal file
View File

@@ -0,0 +1,416 @@
use clap::Subcommand;
use wfe_core::traits::WorkflowRepository;
use crate::error::{Result, SunbeamError};
use crate::output;
use super::host;
#[derive(Subcommand, Debug)]
pub enum WorkflowAction {
/// List workflow instances.
List {
/// Filter by status (runnable, complete, terminated, suspended).
#[arg(long, default_value = "")]
status: String,
},
/// Show status of a workflow instance.
Status {
/// Workflow instance ID.
id: String,
},
/// Retry a failed workflow from its last checkpoint.
Retry {
/// Workflow instance ID.
id: String,
},
/// Cancel a running workflow.
Cancel {
/// Workflow instance ID.
id: String,
},
/// Run a YAML-defined workflow.
Run {
/// Path to workflow YAML file (default: ./workflows.yaml).
#[arg(default_value = "")]
file: String,
},
}
/// Dispatch a `sunbeam workflow <action>` command.
pub async fn dispatch(context_name: &str, action: WorkflowAction) -> Result<()> {
if let WorkflowAction::Run { file } = action {
return run_workflow(&file).await;
}
let h = host::create_host(context_name).await?;
let result = dispatch_with_host(&h, action).await;
host::shutdown_host(h).await;
result
}
/// Inner dispatch that operates on an already-created host. Testable.
pub async fn dispatch_with_host(
h: &wfe::WorkflowHost,
action: WorkflowAction,
) -> Result<()> {
match action {
WorkflowAction::List { status } => list_workflows(h, &status).await,
WorkflowAction::Status { id } => show_workflow_status(h, &id).await,
WorkflowAction::Retry { id } => retry_workflow(h, &id).await,
WorkflowAction::Cancel { id } => cancel_workflow(h, &id).await,
WorkflowAction::Run { .. } => unreachable!("handled above"),
}
}
/// List workflow instances.
pub async fn list_workflows(h: &wfe::WorkflowHost, _status_filter: &str) -> Result<()> {
let now = chrono::Utc::now();
let ids = h
.persistence()
.get_runnable_instances(now)
.await
.map_err(|e| SunbeamError::Other(format!("query workflows: {e}")))?;
if ids.is_empty() {
output::ok("No workflow instances found.");
return Ok(());
}
let instances = h
.persistence()
.get_workflow_instances(&ids)
.await
.map_err(|e| SunbeamError::Other(format!("load workflows: {e}")))?;
let rows: Vec<Vec<String>> = instances
.iter()
.map(|wf| {
vec![
wf.id.clone(),
wf.workflow_definition_id.clone(),
format!("{:?}", wf.status),
]
})
.collect();
println!("{}", output::table(&rows, &["ID", "DEFINITION", "STATUS"]));
Ok(())
}
/// Show status of a single workflow instance.
pub async fn show_workflow_status(h: &wfe::WorkflowHost, id: &str) -> Result<()> {
match h.get_workflow(id).await {
Ok(wf) => {
output::ok(&format!("Workflow: {}", wf.workflow_definition_id));
output::ok(&format!("Status: {:?}", wf.status));
output::ok(&format!("Created: {}", wf.create_time));
if let Some(ct) = wf.complete_time {
output::ok(&format!("Completed: {ct}"));
}
println!();
output::step("Execution pointers:");
let rows: Vec<Vec<String>> = wf
.execution_pointers
.iter()
.map(|ep| {
vec![
ep.step_name
.clone()
.unwrap_or_else(|| format!("step-{}", ep.step_id)),
format!("{:?}", ep.status),
ep.start_time
.map(|t| t.to_string())
.unwrap_or_default(),
ep.end_time.map(|t| t.to_string()).unwrap_or_default(),
format!("{}", ep.retry_count),
]
})
.collect();
println!(
"{}",
output::table(&rows, &["STEP", "STATUS", "STARTED", "ENDED", "RETRIES"])
);
}
Err(e) => {
output::warn(&format!("Workflow instance '{id}' not found: {e}"));
}
}
Ok(())
}
/// Resume a suspended/failed workflow.
pub async fn retry_workflow(h: &wfe::WorkflowHost, id: &str) -> Result<()> {
h.resume_workflow(id)
.await
.map_err(|e| SunbeamError::Other(format!("resume workflow: {e}")))?;
output::ok(&format!("Workflow '{id}' resumed."));
Ok(())
}
/// Terminate a running workflow.
pub async fn cancel_workflow(h: &wfe::WorkflowHost, id: &str) -> Result<()> {
h.terminate_workflow(id)
.await
.map_err(|e| SunbeamError::Other(format!("terminate workflow: {e}")))?;
output::ok(&format!("Workflow '{id}' cancelled."));
Ok(())
}
async fn run_workflow(_file: &str) -> Result<()> {
Err(SunbeamError::Other(
"sunbeam workflow run is not yet implemented".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::{ExecutionResult, WorkflowStatus};
use wfe_core::traits::{StepBody, StepExecutionContext};
#[derive(Default)]
struct NoOp;
#[async_trait::async_trait]
impl StepBody for NoOp {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
Ok(ExecutionResult::next())
}
}
async fn setup_host_with_workflow() -> (wfe::WorkflowHost, String) {
let h = host::create_test_host().await.unwrap();
h.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("test-step")
.end_workflow()
.build("test-def", 1);
h.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&h,
"test-def",
1,
serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap();
(h, instance.id)
}
#[tokio::test]
async fn test_list_workflows_empty() {
let h = host::create_test_host().await.unwrap();
let result = list_workflows(&h, "").await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_show_workflow_status_not_found() {
let h = host::create_test_host().await.unwrap();
let result = show_workflow_status(&h, "nonexistent-id").await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_show_workflow_status_found() {
let (h, id) = setup_host_with_workflow().await;
let result = show_workflow_status(&h, &id).await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_show_status_with_step_details() {
let h = host::create_test_host().await.unwrap();
h.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("step-alpha")
.then::<NoOp>()
.name("step-beta")
.end_workflow()
.build("multi-def", 1);
h.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&h,
"multi-def",
1,
serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(instance.status, WorkflowStatus::Complete);
let result = show_workflow_status(&h, &instance.id).await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_cancel_workflow_completed() {
let (h, id) = setup_host_with_workflow().await;
let result = cancel_workflow(&h, &id).await;
drop(result);
h.stop().await;
}
#[tokio::test]
async fn test_retry_workflow_nonexistent() {
let h = host::create_test_host().await.unwrap();
let result = retry_workflow(&h, "does-not-exist").await;
assert!(result.is_err());
h.stop().await;
}
#[tokio::test]
async fn test_cancel_workflow_nonexistent() {
let h = host::create_test_host().await.unwrap();
let result = cancel_workflow(&h, "does-not-exist").await;
assert!(result.is_err());
h.stop().await;
}
#[tokio::test]
async fn test_run_workflow_not_implemented() {
let result = run_workflow("test.yaml").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not yet implemented"));
}
#[tokio::test]
async fn test_dispatch_with_host_list() {
let h = host::create_test_host().await.unwrap();
let result = dispatch_with_host(
&h,
WorkflowAction::List { status: String::new() },
)
.await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_dispatch_with_host_status() {
let (h, id) = setup_host_with_workflow().await;
let result = dispatch_with_host(
&h,
WorkflowAction::Status { id },
)
.await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_dispatch_with_host_retry_nonexistent() {
let h = host::create_test_host().await.unwrap();
let result = dispatch_with_host(
&h,
WorkflowAction::Retry { id: "nope".to_string() },
)
.await;
assert!(result.is_err());
h.stop().await;
}
#[tokio::test]
async fn test_retry_suspended_workflow() {
let h = host::create_test_host().await.unwrap();
h.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("suspend-step")
.end_workflow()
.build("suspend-def", 1);
h.register_workflow_definition(def).await;
let id = h.start_workflow("suspend-def", 1, serde_json::json!({})).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
// Suspend it
let _ = h.suspend_workflow(&id).await;
// Resume should succeed
let result = retry_workflow(&h, &id).await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_cancel_running_workflow() {
let h = host::create_test_host().await.unwrap();
h.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("cancel-step")
.wait_for("never-event", "never-key")
.name("waiting")
.end_workflow()
.build("cancel-def", 1);
h.register_workflow_definition(def).await;
let id = h.start_workflow("cancel-def", 1, serde_json::json!({})).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let result = cancel_workflow(&h, &id).await;
assert!(result.is_ok());
h.stop().await;
}
#[tokio::test]
async fn test_dispatch_with_host_cancel() {
let (h, id) = setup_host_with_workflow().await;
let result = dispatch_with_host(
&h,
WorkflowAction::Cancel { id },
)
.await;
drop(result);
h.stop().await;
}
#[tokio::test]
async fn test_list_workflows_with_runnable_instance() {
use wfe_core::models::WorkflowInstance;
let h = host::create_test_host().await.unwrap();
// Manually persist a Runnable workflow so get_runnable_instances finds it
let instance = WorkflowInstance::new(
"manual-def",
1,
serde_json::json!({}),
);
h.persistence()
.create_new_workflow(&instance)
.await
.unwrap();
// Now list_workflows should hit the non-empty path
let result = list_workflows(&h, "").await;
assert!(result.is_ok());
h.stop().await;
}
}

281
src/workflows/data.rs Normal file
View File

@@ -0,0 +1,281 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::StepContext;
/// Workflow data for the `seed` workflow.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SeedData {
/// Shared CLI context (domain, kube context, etc.)
#[serde(default, rename = "__ctx")]
pub ctx: Option<StepContext>,
// -- Phase 1: OpenBao init --
pub ob_pod: Option<String>,
pub ob_port: Option<u16>,
pub root_token: Option<String>,
pub initialized: Option<bool>,
pub sealed: Option<bool>,
pub skip_seed: bool,
// -- Phase 2: KV seeding --
/// Accumulated credential values keyed by "path/field".
#[serde(default)]
pub creds: HashMap<String, String>,
/// KV paths that were modified and need writing.
#[serde(default)]
pub dirty_paths: Vec<String>,
// -- Phase 4: PostgreSQL --
pub pg_pod: Option<String>,
// -- Phase 6: Kratos admin --
pub recovery_link: Option<String>,
pub recovery_code: Option<String>,
pub dkim_public_key: Option<String>,
pub admin_identity_id: Option<String>,
}
/// Workflow data for the `up` workflow.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpData {
#[serde(default, rename = "__ctx")]
pub ctx: Option<StepContext>,
pub domain: String,
// -- Vault phase (reused from seed) --
pub ob_pod: Option<String>,
pub ob_port: Option<u16>,
pub root_token: Option<String>,
#[serde(default)]
pub skip_seed: bool,
#[serde(default)]
pub creds: HashMap<String, String>,
#[serde(default)]
pub dirty_paths: Vec<String>,
// -- Postgres phase --
pub pg_pod: Option<String>,
}
/// Workflow data for the `verify` workflow.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VerifyData {
#[serde(default, rename = "__ctx")]
pub ctx: Option<StepContext>,
pub ob_pod: Option<String>,
pub ob_port: Option<u16>,
pub root_token: Option<String>,
pub test_value: Option<String>,
pub synced: bool,
}
/// Workflow data for the `bootstrap` workflow.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BootstrapData {
#[serde(default, rename = "__ctx")]
pub ctx: Option<StepContext>,
pub gitea_pod: Option<String>,
pub gitea_admin_pass: Option<String>,
pub domain: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_ctx() -> StepContext {
StepContext {
domain: "test.local".to_string(),
infra_dir: "/tmp".to_string(),
kube_context: "test".to_string(),
ssh_host: String::new(),
is_production: false,
acme_email: String::new(),
context_name: "default".to_string(),
}
}
// -- SeedData --
#[test]
fn test_seed_data_default() {
let d = SeedData::default();
assert!(d.ctx.is_none());
assert!(d.ob_pod.is_none());
assert!(!d.skip_seed);
assert!(d.creds.is_empty());
assert!(d.dirty_paths.is_empty());
}
#[test]
fn test_seed_data_serialization_roundtrip() {
let mut creds = HashMap::new();
creds.insert("hydra/system-secret".to_string(), "abc123".to_string());
creds.insert("kratos/cookie-secret".to_string(), "xyz789".to_string());
let d = SeedData {
ctx: Some(make_ctx()),
ob_pod: Some("openbao-0".to_string()),
ob_port: Some(8200),
root_token: Some("hvs.test".to_string()),
initialized: Some(true),
sealed: Some(false),
skip_seed: false,
creds,
dirty_paths: vec!["hydra".to_string()],
pg_pod: Some("postgres-1".to_string()),
recovery_link: None,
recovery_code: None,
dkim_public_key: Some("MIIBIjAN...".to_string()),
admin_identity_id: None,
};
let json = serde_json::to_value(&d).unwrap();
let back: SeedData = serde_json::from_value(json.clone()).unwrap();
assert_eq!(back.ob_pod.as_deref(), Some("openbao-0"));
assert_eq!(back.ob_port, Some(8200));
assert_eq!(back.root_token.as_deref(), Some("hvs.test"));
assert_eq!(back.initialized, Some(true));
assert_eq!(back.sealed, Some(false));
assert_eq!(back.creds.len(), 2);
assert_eq!(back.creds["hydra/system-secret"], "abc123");
assert_eq!(back.dirty_paths, vec!["hydra"]);
assert_eq!(back.pg_pod.as_deref(), Some("postgres-1"));
assert_eq!(back.dkim_public_key.as_deref(), Some("MIIBIjAN..."));
// __ctx rename should work
assert!(json.get("__ctx").is_some());
assert!(json.get("ctx").is_none());
}
#[test]
fn test_seed_data_ctx_rename() {
let d = SeedData {
ctx: Some(make_ctx()),
..Default::default()
};
let json = serde_json::to_value(&d).unwrap();
// The field should be serialized as "__ctx", not "ctx"
assert!(json.get("__ctx").is_some());
assert!(json.get("ctx").is_none());
// And deserializes back
let back: SeedData = serde_json::from_value(json).unwrap();
assert!(back.ctx.is_some());
assert_eq!(back.ctx.unwrap().domain, "test.local");
}
#[test]
fn test_seed_data_from_json_without_ctx() {
// Workflow data might not have __ctx initially
let json = serde_json::json!({
"ob_pod": "openbao-0",
"skip_seed": true,
});
let d: SeedData = serde_json::from_value(json).unwrap();
assert!(d.ctx.is_none());
assert_eq!(d.ob_pod.as_deref(), Some("openbao-0"));
assert!(d.skip_seed);
}
// -- UpData --
#[test]
fn test_up_data_default() {
let d = UpData::default();
assert!(d.ctx.is_none());
assert!(d.domain.is_empty());
assert!(!d.skip_seed);
assert!(d.ob_pod.is_none());
assert!(d.creds.is_empty());
assert!(d.pg_pod.is_none());
}
#[test]
fn test_up_data_roundtrip() {
let d = UpData {
ctx: Some(make_ctx()),
domain: "sunbeam.pt".to_string(),
ob_pod: Some("openbao-0".to_string()),
ob_port: Some(8200),
root_token: Some("hvs.test".to_string()),
skip_seed: false,
creds: HashMap::new(),
dirty_paths: vec![],
pg_pod: Some("postgres-1".to_string()),
};
let json = serde_json::to_value(&d).unwrap();
let back: UpData = serde_json::from_value(json).unwrap();
assert_eq!(back.domain, "sunbeam.pt");
assert!(back.ctx.is_some());
assert_eq!(back.ob_pod.as_deref(), Some("openbao-0"));
assert_eq!(back.pg_pod.as_deref(), Some("postgres-1"));
}
// -- VerifyData --
#[test]
fn test_verify_data_default() {
let d = VerifyData::default();
assert!(!d.synced);
assert!(d.test_value.is_none());
}
#[test]
fn test_verify_data_roundtrip() {
let d = VerifyData {
ctx: Some(make_ctx()),
ob_pod: Some("openbao-0".to_string()),
ob_port: Some(8200),
root_token: Some("root".to_string()),
test_value: Some("sentinel-abc".to_string()),
synced: true,
};
let json = serde_json::to_value(&d).unwrap();
let back: VerifyData = serde_json::from_value(json).unwrap();
assert!(back.synced);
assert_eq!(back.test_value.as_deref(), Some("sentinel-abc"));
}
// -- BootstrapData --
#[test]
fn test_bootstrap_data_default() {
let d = BootstrapData::default();
assert!(d.gitea_pod.is_none());
assert!(d.gitea_admin_pass.is_none());
assert!(d.domain.is_none());
}
#[test]
fn test_bootstrap_data_roundtrip() {
let d = BootstrapData {
ctx: Some(make_ctx()),
gitea_pod: Some("gitea-0".to_string()),
gitea_admin_pass: Some("admin123".to_string()),
domain: Some("test.local".to_string()),
};
let json = serde_json::to_value(&d).unwrap();
let back: BootstrapData = serde_json::from_value(json).unwrap();
assert_eq!(back.gitea_pod.as_deref(), Some("gitea-0"));
assert_eq!(back.gitea_admin_pass.as_deref(), Some("admin123"));
}
// -- Cross-data-type: ensure WFE can use serde_json::Value as data --
#[test]
fn test_seed_data_as_json_value() {
let d = SeedData {
ctx: Some(make_ctx()),
..Default::default()
};
// WFE stores data as serde_json::Value — verify this works
let val = serde_json::to_value(&d).unwrap();
assert!(val.is_object());
// And can be read back
let back: SeedData = serde_json::from_value(val).unwrap();
assert!(back.ctx.is_some());
}
}

328
src/workflows/host.rs Normal file
View File

@@ -0,0 +1,328 @@
use std::path::PathBuf;
use std::sync::Arc;
use wfe::WorkflowHostBuilder;
use wfe_core::test_support::{InMemoryLockProvider, InMemoryQueueProvider};
use wfe_sqlite::SqlitePersistenceProvider;
use crate::error::{Result, SunbeamError};
/// Build and start a WorkflowHost with a SQLite database at the given path.
///
/// Lock and queue providers are in-memory (single-process, non-distributed).
pub async fn create_host_at(db_path: &std::path::Path) -> Result<wfe::WorkflowHost> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
SunbeamError::Io {
context: format!("create workflow db dir: {}", parent.display()),
source: e,
}
})?;
}
let db_url = format!("sqlite://{}?mode=rwc", db_path.display());
let persistence = SqlitePersistenceProvider::new(&db_url)
.await
.map_err(|e| SunbeamError::Other(format!("workflow db init: {e}")))?;
let host = WorkflowHostBuilder::new()
.use_persistence(Arc::new(persistence))
.use_lock_provider(Arc::new(InMemoryLockProvider::new()))
.use_queue_provider(Arc::new(InMemoryQueueProvider::new()))
.build()
.map_err(|e| SunbeamError::Other(format!("workflow host build: {e}")))?;
host.start()
.await
.map_err(|e| SunbeamError::Other(format!("workflow host start: {e}")))?;
Ok(host)
}
/// Build and start a WorkflowHost configured for the given context.
///
/// The host uses a per-context SQLite database at `~/.sunbeam/{context}/workflows.db`.
pub async fn create_host(context_name: &str) -> Result<wfe::WorkflowHost> {
let db_path = workflow_db_path(context_name);
create_host_at(&db_path).await
}
/// Gracefully shut down the host.
pub async fn shutdown_host(host: wfe::WorkflowHost) {
host.stop().await;
}
/// Create a host backed by an in-memory SQLite database (for tests).
pub async fn create_test_host() -> Result<wfe::WorkflowHost> {
let persistence = SqlitePersistenceProvider::new("sqlite::memory:")
.await
.map_err(|e| SunbeamError::Other(format!("in-memory db init: {e}")))?;
let host = WorkflowHostBuilder::new()
.use_persistence(Arc::new(persistence))
.use_lock_provider(Arc::new(InMemoryLockProvider::new()))
.use_queue_provider(Arc::new(InMemoryQueueProvider::new()))
.build()
.map_err(|e| SunbeamError::Other(format!("test host build: {e}")))?;
host.start()
.await
.map_err(|e| SunbeamError::Other(format!("test host start: {e}")))?;
Ok(host)
}
/// Resolve the SQLite database path for a context.
pub fn workflow_db_path(context_name: &str) -> PathBuf {
crate::config::context_dir(context_name).join("workflows.db")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::{ExecutionResult, WorkflowStatus};
use wfe_core::traits::{StepBody, StepExecutionContext, WorkflowRepository};
#[derive(Default)]
struct NoOp;
#[async_trait::async_trait]
impl StepBody for NoOp {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
Ok(ExecutionResult::next())
}
}
#[test]
fn test_workflow_db_path_default() {
let path = workflow_db_path("");
assert!(path.ends_with(".sunbeam/default/workflows.db"));
}
#[test]
fn test_workflow_db_path_named() {
let path = workflow_db_path("production");
assert!(path.ends_with(".sunbeam/production/workflows.db"));
}
#[test]
fn test_workflow_db_path_custom() {
let path = workflow_db_path("staging");
assert!(path.ends_with(".sunbeam/staging/workflows.db"));
assert!(!path.to_string_lossy().contains("default"));
}
#[tokio::test]
async fn test_create_test_host() {
let host = create_test_host().await.unwrap();
let now = chrono::Utc::now();
let ids = host.persistence().get_runnable_instances(now).await.unwrap();
assert!(ids.is_empty());
host.stop().await;
}
#[tokio::test]
async fn test_create_host_at_with_temp_dir() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("ctx").join("workflows.db");
let host = create_host_at(&db_path).await.unwrap();
// DB file should be created
assert!(db_path.exists());
// Should be queryable
let now = chrono::Utc::now();
let ids = host.persistence().get_runnable_instances(now).await.unwrap();
assert!(ids.is_empty());
shutdown_host(host).await;
}
#[tokio::test]
async fn test_create_host_at_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("deep").join("nested").join("workflows.db");
let host = create_host_at(&db_path).await.unwrap();
assert!(db_path.exists());
shutdown_host(host).await;
}
#[tokio::test]
async fn test_shutdown_host_is_clean() {
let host = create_test_host().await.unwrap();
// Should not panic or hang
shutdown_host(host).await;
}
#[tokio::test]
async fn test_host_start_and_run_trivial_workflow() {
let host = create_test_host().await.unwrap();
host.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("no-op")
.end_workflow()
.build("test-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"test-wf",
1,
serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(instance.status, WorkflowStatus::Complete);
assert_eq!(instance.workflow_definition_id, "test-wf");
assert_eq!(instance.execution_pointers.len(), 1);
host.stop().await;
}
#[tokio::test]
async fn test_host_multi_step_workflow() {
let host = create_test_host().await.unwrap();
host.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("step-a")
.then::<NoOp>()
.name("step-b")
.then::<NoOp>()
.name("step-c")
.end_workflow()
.build("multi-step", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"multi-step",
1,
serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(instance.status, WorkflowStatus::Complete);
assert_eq!(instance.execution_pointers.len(), 3);
host.stop().await;
}
#[tokio::test]
async fn test_host_workflow_with_data_output() {
let host = create_test_host().await.unwrap();
#[derive(Default)]
struct OutputStep;
#[async_trait::async_trait]
impl StepBody for OutputStep {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({"test_key": "test_value"}));
Ok(result)
}
}
host.register_step::<OutputStep>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<OutputStep>()
.name("output")
.end_workflow()
.build("output-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"output-wf",
1,
serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(instance.status, WorkflowStatus::Complete);
assert_eq!(instance.data["test_key"], "test_value");
host.stop().await;
}
#[tokio::test]
async fn test_host_get_workflow_by_id() {
let host = create_test_host().await.unwrap();
host.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("get-test")
.end_workflow()
.build("get-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"get-wf",
1,
serde_json::json!({"initial": true}),
Duration::from_secs(5),
)
.await
.unwrap();
let fetched = host.get_workflow(&instance.id).await.unwrap();
assert_eq!(fetched.id, instance.id);
assert_eq!(fetched.workflow_definition_id, "get-wf");
assert_eq!(fetched.status, WorkflowStatus::Complete);
host.stop().await;
}
#[tokio::test]
async fn test_host_with_file_sqlite_runs_workflow() {
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("wf-test").join("workflows.db");
let host = create_host_at(&db_path).await.unwrap();
host.register_step::<NoOp>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<NoOp>()
.name("file-test")
.end_workflow()
.build("file-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"file-wf",
1,
serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(instance.status, WorkflowStatus::Complete);
shutdown_host(host).await;
}
}

223
src/workflows/mod.rs Normal file
View File

@@ -0,0 +1,223 @@
pub mod cmd;
pub mod data;
pub mod host;
pub mod primitives;
pub mod seed;
pub mod up;
pub mod verify;
pub mod bootstrap;
use serde::{Deserialize, Serialize};
use crate::config;
use crate::error::Result;
/// Serializable context passed through workflow data.
///
/// Steps reconstruct transient handles (kube::Client, BaoClient) from these
/// fields at runtime — they are not serializable, so we store just enough
/// to recreate them.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepContext {
pub domain: String,
pub infra_dir: String,
pub kube_context: String,
pub ssh_host: String,
pub is_production: bool,
pub acme_email: String,
/// The config context name, used for per-context DB paths.
pub context_name: String,
}
impl StepContext {
/// Build a StepContext from the currently active CLI context.
pub fn from_active() -> Self {
let ctx = config::active_context();
let cfg = config::load_config();
Self::from_config(ctx, &cfg.current_context)
}
/// Build a StepContext from a config Context and context name.
/// Separated from `from_active()` to allow unit testing without global state.
pub fn from_config(ctx: &config::Context, current_context: &str) -> Self {
let context_name = if current_context.is_empty() {
"default".to_string()
} else {
current_context.to_string()
};
StepContext {
domain: ctx.domain.clone(),
infra_dir: ctx.infra_dir.clone(),
kube_context: if ctx.kube_context.is_empty() {
"sunbeam".to_string()
} else {
ctx.kube_context.clone()
},
ssh_host: ctx.ssh_host.clone(),
is_production: !ctx.ssh_host.is_empty(),
acme_email: ctx.acme_email.clone(),
context_name,
}
}
/// Reconstruct a Kubernetes client from the stored context.
pub async fn kube_client(&self) -> Result<kube::Client> {
crate::kube::get_client().await.map(|c| c.clone())
}
/// Build an OpenBao HTTP client from a local port and token.
pub fn bao_client(&self, port: u16, token: &str) -> crate::openbao::BaoClient {
crate::openbao::BaoClient::with_token(
&format!("http://127.0.0.1:{port}"),
token,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_ctx() -> StepContext {
StepContext {
domain: "test.local".to_string(),
infra_dir: "/tmp/infra".to_string(),
kube_context: "test-cluster".to_string(),
ssh_host: String::new(),
is_production: false,
acme_email: "test@test.local".to_string(),
context_name: "test".to_string(),
}
}
fn production_ctx() -> StepContext {
StepContext {
domain: "sunbeam.pt".to_string(),
infra_dir: "/srv/infra".to_string(),
kube_context: "production".to_string(),
ssh_host: "user@server.example.com".to_string(),
is_production: true,
acme_email: "ops@sunbeam.pt".to_string(),
context_name: "production".to_string(),
}
}
#[test]
fn test_step_context_serialization_roundtrip() {
let ctx = test_ctx();
let json = serde_json::to_value(&ctx).unwrap();
let deserialized: StepContext = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.domain, "test.local");
assert_eq!(deserialized.kube_context, "test-cluster");
assert!(!deserialized.is_production);
assert_eq!(deserialized.context_name, "test");
}
#[test]
fn test_step_context_production_flag() {
let local = test_ctx();
assert!(!local.is_production);
assert!(local.ssh_host.is_empty());
let prod = production_ctx();
assert!(prod.is_production);
assert!(!prod.ssh_host.is_empty());
}
#[test]
fn test_step_context_bao_client_construction() {
let ctx = test_ctx();
let client = ctx.bao_client(8200, "test-token");
// BaoClient is opaque, but we can verify it doesn't panic
// and the base_url is constructed correctly by checking it exists
drop(client);
}
#[test]
fn test_step_context_bao_client_ephemeral_port() {
let ctx = test_ctx();
let client = ctx.bao_client(49152, "some-token");
drop(client);
}
#[test]
fn test_step_context_embedded_in_json() {
let ctx = test_ctx();
let wrapper = serde_json::json!({
"__ctx": ctx,
"some_field": "value",
});
let extracted: StepContext =
serde_json::from_value(wrapper["__ctx"].clone()).unwrap();
assert_eq!(extracted.domain, "test.local");
}
#[test]
fn test_from_config_local_context() {
let ctx = crate::config::Context {
domain: "local.dev".to_string(),
kube_context: "k3s-local".to_string(),
ssh_host: String::new(),
infra_dir: "/home/user/infra".to_string(),
acme_email: "admin@local.dev".to_string(),
};
let sc = StepContext::from_config(&ctx, "local");
assert_eq!(sc.domain, "local.dev");
assert_eq!(sc.kube_context, "k3s-local");
assert!(!sc.is_production);
assert_eq!(sc.context_name, "local");
assert_eq!(sc.infra_dir, "/home/user/infra");
assert_eq!(sc.acme_email, "admin@local.dev");
}
#[test]
fn test_from_config_production_context() {
let ctx = crate::config::Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
ssh_host: "sienna@62.210.145.138".to_string(),
infra_dir: "/srv/infra".to_string(),
acme_email: "ops@sunbeam.pt".to_string(),
};
let sc = StepContext::from_config(&ctx, "production");
assert!(sc.is_production);
assert_eq!(sc.ssh_host, "sienna@62.210.145.138");
assert_eq!(sc.context_name, "production");
}
#[test]
fn test_from_config_empty_context_name_defaults() {
let ctx = crate::config::Context::default();
let sc = StepContext::from_config(&ctx, "");
assert_eq!(sc.context_name, "default");
}
#[test]
fn test_from_config_empty_kube_context_defaults_sunbeam() {
let ctx = crate::config::Context {
kube_context: String::new(),
..Default::default()
};
let sc = StepContext::from_config(&ctx, "test");
assert_eq!(sc.kube_context, "sunbeam");
}
#[test]
fn test_step_context_empty_fields() {
let ctx = StepContext {
domain: String::new(),
infra_dir: String::new(),
kube_context: String::new(),
ssh_host: String::new(),
is_production: false,
acme_email: String::new(),
context_name: String::new(),
};
let json = serde_json::to_string(&ctx).unwrap();
let back: StepContext = serde_json::from_str(&json).unwrap();
assert!(back.domain.is_empty());
assert!(!back.is_production);
}
}

View File

@@ -0,0 +1,236 @@
//! Seed workflow definition — linear sequence of all seed steps.
use serde_json::json;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowDefinition;
use super::steps;
use crate::workflows::primitives::{
CollectCredentials, CreateK8sSecret, CreatePGDatabase, CreatePGRole,
EnableVaultAuth, EnsureNamespace, SeedKVPath, WriteKVPath,
WriteVaultAuthConfig, WriteVaultPolicy, WriteVaultRole,
};
use crate::workflows::primitives::kv_service_configs;
use steps::postgres::pg_db_map;
/// Build the seed workflow definition.
pub fn build() -> WorkflowDefinition {
WorkflowBuilder::<serde_json::Value>::new()
.start_with::<steps::FindOpenBaoPod>()
.name("find-openbao-pod")
.then::<steps::WaitPodRunning>()
.name("wait-pod-running")
.then::<steps::InitOrUnsealOpenBao>()
.name("init-or-unseal-openbao")
.parallel(|p| {
let mut p = p;
for cfg in kv_service_configs::all_service_configs() {
let service = cfg["service"].as_str().unwrap().to_string();
p = p.branch(|b| {
let seed_id = b.add_step_typed::<SeedKVPath>(
&format!("seed-{service}"), Some(cfg.clone()));
let write_id = b.add_step_typed::<WriteKVPath>(
&format!("write-{service}"), Some(json!({"service": &service})));
b.wire_outcome(seed_id, write_id, None);
});
}
p.branch(|b| {
let seed_id = b.add_step_typed::<SeedKVPath>(
"seed-kratos-admin", Some(kv_service_configs::kratos_admin_config()));
let write_id = b.add_step_typed::<WriteKVPath>(
"write-kratos-admin", Some(json!({"service": "kratos-admin"})));
b.wire_outcome(seed_id, write_id, None);
})
})
.then::<CollectCredentials>()
.name("collect-credentials")
.then::<EnableVaultAuth>()
.name("enable-k8s-auth")
.config(json!({"mount": "kubernetes", "type": "kubernetes"}))
.then::<WriteVaultAuthConfig>()
.name("write-k8s-auth-config")
.config(json!({"mount": "kubernetes", "config": {
"kubernetes_host": "https://kubernetes.default.svc.cluster.local"
}}))
.then::<WriteVaultPolicy>()
.name("write-vso-policy")
.config(json!({"name": "vso-reader", "hcl": concat!(
"path \"secret/data/*\" { capabilities = [\"read\"] }\n",
"path \"secret/metadata/*\" { capabilities = [\"read\", \"list\"] }\n",
"path \"database/static-creds/*\" { capabilities = [\"read\"] }\n",
)}))
.then::<WriteVaultRole>()
.name("write-vso-role")
.config(json!({"mount": "kubernetes", "role": "vso", "config": {
"bound_service_account_names": "default",
"bound_service_account_namespaces": "ory,devtools,storage,lasuite,stalwart,matrix,media,data,monitoring,cert-manager",
"policies": "vso-reader",
"ttl": "1h"
}}))
.then::<steps::WaitForPostgres>()
.name("wait-for-postgres")
.parallel(|p| {
let db_map = pg_db_map();
let mut p = p;
for (user, db) in &db_map {
p = p.branch(|b| {
let role_id = b.add_step_typed::<CreatePGRole>(
&format!("pg-role-{user}"),
Some(json!({"username": user})),
);
let db_id = b.add_step_typed::<CreatePGDatabase>(
&format!("pg-db-{db}"),
Some(json!({"dbname": db, "owner": user})),
);
b.wire_outcome(role_id, db_id, None);
});
}
p
})
.then::<steps::ConfigureDatabaseEngine>()
.name("configure-database-engine")
.parallel(|p| p
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-ory",
Some(json!({"namespace": "ory"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-hydra",
Some(json!({"namespace":"ory","name":"hydra","data":{
"secretsSystem":"hydra-system-secret",
"secretsCookie":"hydra-cookie-secret",
"pairwise-salt":"hydra-pairwise-salt"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-kratos-app",
Some(json!({"namespace":"ory","name":"kratos-app-secrets","data":{
"secretsDefault":"kratos-secrets-default",
"secretsCookie":"kratos-secrets-cookie"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
})
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-devtools",
Some(json!({"namespace": "devtools"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-gitea-s3",
Some(json!({"namespace":"devtools","name":"gitea-s3-credentials","data":{
"access-key":"s3-access-key",
"secret-key":"s3-secret-key"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-gitea-admin",
Some(json!({"namespace":"devtools","name":"gitea-admin-credentials","data":{
"username":"literal:gitea_admin",
"password":"gitea-admin-password"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
})
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-storage",
Some(json!({"namespace": "storage"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-seaweedfs-s3-creds",
Some(json!({"namespace":"storage","name":"seaweedfs-s3-credentials","data":{
"S3_ACCESS_KEY":"s3-access-key",
"S3_SECRET_KEY":"s3-secret-key"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-seaweedfs-s3-json",
Some(json!({"namespace":"storage","name":"seaweedfs-s3-json","data":{
"s3.json":"s3_json"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
})
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-lasuite",
Some(json!({"namespace": "lasuite"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-lasuite-s3",
Some(json!({"namespace":"lasuite","name":"seaweedfs-s3-credentials","data":{
"S3_ACCESS_KEY":"s3-access-key",
"S3_SECRET_KEY":"s3-secret-key"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-hive-oidc",
Some(json!({"namespace":"lasuite","name":"hive-oidc","data":{
"client-id":"hive-oidc-client-id",
"client-secret":"hive-oidc-client-secret"
}})));
let s3 = b.add_step_typed::<CreateK8sSecret>("secret-people-django",
Some(json!({"namespace":"lasuite","name":"people-django-secret","data":{
"DJANGO_SECRET_KEY":"people-django-secret"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
b.wire_outcome(s2, s3, None);
})
.branch(|b| {
b.add_step_typed::<EnsureNamespace>("ensure-ns-matrix",
Some(json!({"namespace": "matrix"})));
})
.branch(|b| {
b.add_step_typed::<EnsureNamespace>("ensure-ns-media",
Some(json!({"namespace": "media"})));
})
.branch(|b| {
b.add_step_typed::<EnsureNamespace>("ensure-ns-monitoring",
Some(json!({"namespace": "monitoring"})));
})
)
.then::<steps::SyncGiteaAdminPassword>()
.name("sync-gitea-admin-password")
.then::<steps::SeedKratosAdminIdentity>()
.name("seed-kratos-admin-identity")
.then::<steps::PrintSeedOutputs>()
.name("print-seed-outputs")
.end_workflow()
.build("seed", 2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_returns_valid_definition() {
let def = build();
assert_eq!(def.id, "seed");
assert_eq!(def.version, 2);
// More steps now due to parallel PG branches
assert!(def.steps.len() > 13, "expected >13 steps, got {}", def.steps.len());
}
#[test]
fn test_has_pg_role_and_db_steps() {
let def = build();
let role_steps: Vec<_> = def.steps.iter()
.filter(|s| s.step_type.contains("CreatePGRole"))
.collect();
let db_steps: Vec<_> = def.steps.iter()
.filter(|s| s.step_type.contains("CreatePGDatabase"))
.collect();
assert_eq!(role_steps.len(), 15, "should have 15 CreatePGRole steps");
assert_eq!(db_steps.len(), 15, "should have 15 CreatePGDatabase steps");
}
#[test]
fn test_pg_steps_have_config() {
let def = build();
for s in &def.steps {
if s.step_type.contains("CreatePGRole") {
let config = s.step_config.as_ref().expect("CreatePGRole missing config");
assert!(config.get("username").is_some());
}
if s.step_type.contains("CreatePGDatabase") {
let config = s.step_config.as_ref().expect("CreatePGDatabase missing config");
assert!(config.get("dbname").is_some());
assert!(config.get("owner").is_some());
}
}
}
#[test]
fn test_first_and_last_steps() {
let def = build();
assert_eq!(def.steps[0].name, Some("find-openbao-pod".into()));
let last = def.steps.last().unwrap();
assert_eq!(last.name, Some("print-seed-outputs".into()));
}
}

183
src/workflows/seed/mod.rs Normal file
View File

@@ -0,0 +1,183 @@
//! Seed workflow — orchestrates OpenBao init, KV seeding, Postgres setup,
//! K8s secret mirroring, and Kratos admin identity creation.
pub mod definition;
pub mod steps;
use crate::output;
/// Register all seed workflow steps and the workflow definition with a host.
pub async fn register(host: &wfe::WorkflowHost) {
// Primitive steps (config-driven, reusable)
host.register_step::<crate::workflows::primitives::CreatePGRole>().await;
host.register_step::<crate::workflows::primitives::CreatePGDatabase>().await;
host.register_step::<crate::workflows::primitives::EnsureNamespace>().await;
host.register_step::<crate::workflows::primitives::CreateK8sSecret>().await;
host.register_step::<crate::workflows::primitives::EnableVaultAuth>().await;
host.register_step::<crate::workflows::primitives::WriteVaultAuthConfig>().await;
host.register_step::<crate::workflows::primitives::WriteVaultPolicy>().await;
host.register_step::<crate::workflows::primitives::WriteVaultRole>().await;
host.register_step::<crate::workflows::primitives::SeedKVPath>().await;
host.register_step::<crate::workflows::primitives::WriteKVPath>().await;
host.register_step::<crate::workflows::primitives::CollectCredentials>().await;
// Seed-specific steps
host.register_step::<steps::FindOpenBaoPod>().await;
host.register_step::<steps::WaitPodRunning>().await;
host.register_step::<steps::InitOrUnsealOpenBao>().await;
host.register_step::<steps::WaitForPostgres>().await;
host.register_step::<steps::ConfigureDatabaseEngine>().await;
host.register_step::<steps::SyncGiteaAdminPassword>().await;
host.register_step::<steps::SeedKratosAdminIdentity>().await;
host.register_step::<steps::PrintSeedOutputs>().await;
// Register workflow definition
host.register_workflow_definition(definition::build()).await;
}
/// Print a summary of the completed seed workflow.
pub fn print_summary(instance: &wfe_core::models::WorkflowInstance) {
output::step("Seed workflow summary:");
for ep in &instance.execution_pointers {
let fallback = format!("step-{}", ep.step_id);
let name = ep.step_name.as_deref().unwrap_or(&fallback);
let status = format!("{:?}", ep.status);
let duration = match (ep.start_time, ep.end_time) {
(Some(start), Some(end)) => {
let d = end - start;
format!("{}ms", d.num_milliseconds())
}
_ => "-".to_string(),
};
output::ok(&format!(" {name:<40} {status:<12} {duration}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::models::WorkflowStatus;
#[tokio::test]
async fn test_register_all_steps_and_definition() {
let host = crate::workflows::host::create_test_host().await.unwrap();
register(&host).await;
let def = definition::build();
assert_eq!(def.id, "seed");
assert!(def.steps.len() > 13);
// Run a minimal skip-path test to prove steps are registered
let skip_def = wfe_core::builder::WorkflowBuilder::<serde_json::Value>::new()
.start_with::<steps::WaitPodRunning>()
.name("wait-pod-running")
.then::<steps::ConfigureDatabaseEngine>()
.name("configure-db")
.then::<steps::SyncGiteaAdminPassword>()
.name("sync-gitea")
.then::<steps::PrintSeedOutputs>()
.name("print-outputs")
.end_workflow()
.build("seed-skip-test", 1);
host.register_workflow_definition(skip_def).await;
let instance = run_workflow_sync(
&host,
"seed-skip-test",
1,
serde_json::json!({ "skip_seed": true }),
Duration::from_secs(10),
)
.await
.unwrap();
assert_eq!(instance.status, WorkflowStatus::Complete);
host.stop().await;
}
#[tokio::test]
async fn test_print_summary_with_completed_workflow() {
let host = crate::workflows::host::create_test_host().await.unwrap();
register(&host).await;
let def = wfe_core::builder::WorkflowBuilder::<serde_json::Value>::new()
.start_with::<steps::WaitPodRunning>()
.name("wait-pod-running")
.then::<steps::PrintSeedOutputs>()
.name("print-seed-outputs")
.end_workflow()
.build("summary-test", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"summary-test",
1,
serde_json::json!({ "skip_seed": true }),
Duration::from_secs(5),
)
.await
.unwrap();
// Should not panic — just prints to stdout
print_summary(&instance);
}
#[tokio::test]
async fn test_print_summary_handles_both_named_and_unnamed_steps() {
let host = crate::workflows::host::create_test_host().await.unwrap();
register(&host).await;
let def = wfe_core::builder::WorkflowBuilder::<serde_json::Value>::new()
.start_with::<steps::WaitPodRunning>()
.name("wait-pod-running")
.then::<steps::PrintSeedOutputs>()
.name("print-seed-outputs")
.end_workflow()
.build("names-test", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(
&host,
"names-test",
1,
serde_json::json!({ "skip_seed": true }),
Duration::from_secs(5),
)
.await
.unwrap();
// print_summary should handle both named and unnamed steps gracefully
print_summary(&instance);
assert_eq!(instance.execution_pointers.len(), 2);
}
#[tokio::test]
async fn test_print_summary_with_missing_step_names() {
// Construct a synthetic instance with no step names to exercise fallback
let mut instance = wfe_core::models::WorkflowInstance::new("test", 1, serde_json::json!({}));
let mut ep = wfe_core::models::ExecutionPointer::new(0);
ep.step_name = None;
ep.status = wfe_core::models::PointerStatus::Complete;
ep.start_time = Some(chrono::Utc::now());
ep.end_time = Some(chrono::Utc::now());
instance.execution_pointers.push(ep);
// Should not panic — uses "step-0" fallback
print_summary(&instance);
}
#[tokio::test]
async fn test_print_summary_with_missing_times() {
let mut instance = wfe_core::models::WorkflowInstance::new("test", 1, serde_json::json!({}));
let mut ep = wfe_core::models::ExecutionPointer::new(0);
ep.step_name = Some("test-step".to_string());
ep.status = wfe_core::models::PointerStatus::Complete;
ep.start_time = None;
ep.end_time = None;
instance.execution_pointers.push(ep);
// Should print "-" for duration
print_summary(&instance);
}
}

View File

@@ -0,0 +1,454 @@
//! Kratos admin identity steps: seed admin identity, print outputs.
use std::collections::HashMap;
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::error::SunbeamError;
use crate::kube as k;
use crate::openbao::BaoClient;
use crate::output::{ok, warn};
use crate::secrets::{self, KratosIdentity, KratosRecovery, ADMIN_USERNAME};
use crate::workflows::data::SeedData;
// ── Pure helpers (testable without K8s) ─────────────────────────────────────
/// Strip PEM header/footer lines and whitespace from a public key,
/// returning the raw base64 content.
pub(crate) fn strip_pem_headers(pem: &str) -> String {
pem.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("-----BEGIN RSA PUBLIC KEY-----", "")
.replace("-----END RSA PUBLIC KEY-----", "")
.split_whitespace()
.collect()
}
/// Format a DKIM DNS TXT record from domain and base64-encoded public key.
pub(crate) fn format_dkim_record(domain: &str, b64_key: &str) -> String {
format!("default._domainkey.{domain} TXT \"v=DKIM1; k=rsa; p={b64_key}\"")
}
/// Build the admin email from the domain.
pub(crate) fn admin_email(domain: &str) -> String {
format!("{ADMIN_USERNAME}@{domain}")
}
// ── SeedKratosAdminIdentity ─────────────────────────────────────────────────
/// Port-forward to Kratos, check/create admin identity, generate recovery code,
/// update OpenBao.
#[derive(Default)]
pub struct SeedKratosAdminIdentity;
#[async_trait::async_trait]
impl StepBody for SeedKratosAdminIdentity {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data: SeedData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
if data.skip_seed {
return Ok(ExecutionResult::next());
}
let ob_pod = match &data.ob_pod {
Some(p) => p.clone(),
None => return Ok(ExecutionResult::next()),
};
let root_token = match &data.root_token {
Some(t) if !t.is_empty() => t.clone(),
_ => return Ok(ExecutionResult::next()),
};
let domain = match k::get_domain().await {
Ok(d) => d,
Err(e) => {
warn(&format!("Could not determine domain: {e}"));
return Ok(ExecutionResult::next());
}
};
let admin_email = admin_email(&domain);
ok(&format!(
"Ensuring Kratos admin identity ({admin_email})..."
));
let pf_bao = secrets::port_forward("data", &ob_pod, 8200).await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let bao_url = format!("http://127.0.0.1:{}", pf_bao.local_port);
let bao = BaoClient::with_token(&bao_url, &root_token);
let result: std::result::Result<(String, String, String), SunbeamError> = async {
let pf = match secrets::port_forward_svc(
"ory",
"app.kubernetes.io/name=kratos-admin",
80,
)
.await
{
Ok(pf) => pf,
Err(_) => {
secrets::port_forward_svc("ory", "app.kubernetes.io/name=kratos", 4434)
.await
.map_err(|e| {
SunbeamError::Other(format!(
"Could not port-forward to Kratos admin API: {e}"
))
})?
}
};
let base = format!("http://127.0.0.1:{}", pf.local_port);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let http = reqwest::Client::new();
let resp = http
.get(format!(
"{base}/admin/identities?credentials_identifier={admin_email}&page_size=1"
))
.header("Accept", "application/json")
.send()
.await?;
let identities: Vec<KratosIdentity> = resp.json().await.unwrap_or_default();
let identity_id = if let Some(existing) = identities.first() {
ok(&format!(
" admin identity exists ({}...)",
&existing.id[..8.min(existing.id.len())]
));
existing.id.clone()
} else {
let resp = http
.post(format!("{base}/admin/identities"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&serde_json::json!({
"schema_id": "employee",
"traits": {"email": admin_email},
"state": "active",
}))
.send()
.await?;
let identity: KratosIdentity = resp
.json()
.await
.map_err(|e| SunbeamError::Other(e.to_string()))?;
ok(&format!(
" created admin identity ({}...)",
&identity.id[..8.min(identity.id.len())]
));
identity.id
};
let resp = http
.post(format!("{base}/admin/recovery/code"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&serde_json::json!({
"identity_id": identity_id,
"expires_in": "24h",
}))
.send()
.await?;
let recovery: KratosRecovery = resp.json().await.unwrap_or(KratosRecovery {
recovery_link: String::new(),
recovery_code: String::new(),
});
let mut patch_data = HashMap::new();
patch_data.insert("admin-identity-ids".to_string(), admin_email.clone());
let _ = bao.kv_patch("secret", "kratos-admin", &patch_data).await;
ok(&format!(" ADMIN_IDENTITY_IDS set to {admin_email}"));
Ok((recovery.recovery_link, recovery.recovery_code, identity_id))
}
.await;
let mut output = ExecutionResult::next();
match result {
Ok((recovery_link, recovery_code, identity_id)) => {
output.output_data = Some(serde_json::json!({
"recovery_link": recovery_link,
"recovery_code": recovery_code,
"admin_identity_id": identity_id,
}));
}
Err(e) => {
warn(&format!(
"Could not seed Kratos admin identity (Kratos may not be ready): {e}"
));
}
}
Ok(output)
}
}
// ── PrintSeedOutputs ────────────────────────────────────────────────────────
/// Print DKIM record and recovery link/code.
#[derive(Default)]
pub struct PrintSeedOutputs;
#[async_trait::async_trait]
impl StepBody for PrintSeedOutputs {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data: SeedData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
if data.skip_seed {
ok("Seed skipped (OpenBao not available).");
return Ok(ExecutionResult::next());
}
if let Some(ref link) = data.recovery_link {
if !link.is_empty() {
ok("Admin recovery link (valid 24h):");
println!(" {link}");
}
}
if let Some(ref code) = data.recovery_code {
if !code.is_empty() {
ok("Admin recovery code (enter on the page above):");
println!(" {code}");
}
}
let dkim_pub = data
.creds
.get("messages-dkim-public-key")
.cloned()
.unwrap_or_default();
if !dkim_pub.is_empty() {
let b64_key = strip_pem_headers(&dkim_pub);
if let Ok(domain) = k::get_domain().await {
ok("DKIM DNS record (add to DNS at your registrar):");
println!(" {}", format_dkim_record(&domain, &b64_key));
}
}
ok("All secrets seeded.");
Ok(ExecutionResult::next())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowStatus;
async fn run_step<S: StepBody + Default + 'static>(
data: serde_json::Value,
) -> wfe_core::models::WorkflowInstance {
let host = crate::workflows::host::create_test_host().await.unwrap();
host.register_step::<S>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<S>()
.name("test-step")
.end_workflow()
.build("test-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(&host, "test-wf", 1, data, Duration::from_secs(5))
.await
.unwrap();
host.stop().await;
instance
}
// ── SeedKratosAdminIdentity ─────────────────────────────────────────
#[tokio::test]
async fn test_seed_kratos_admin_skip_seed() {
let data = serde_json::json!({ "skip_seed": true });
let instance = run_step::<SeedKratosAdminIdentity>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_seed_kratos_admin_no_ob_pod() {
let data = serde_json::json!({ "skip_seed": false });
let instance = run_step::<SeedKratosAdminIdentity>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_seed_kratos_admin_null_ob_pod() {
let data = serde_json::json!({ "skip_seed": false, "ob_pod": null });
let instance = run_step::<SeedKratosAdminIdentity>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_seed_kratos_admin_no_root_token() {
let data = serde_json::json!({ "skip_seed": false, "ob_pod": "openbao-0" });
let instance = run_step::<SeedKratosAdminIdentity>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_seed_kratos_admin_empty_root_token() {
let data = serde_json::json!({
"skip_seed": false,
"ob_pod": "openbao-0",
"root_token": "",
});
let instance = run_step::<SeedKratosAdminIdentity>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
// ── PrintSeedOutputs ────────────────────────────────────────────────
#[tokio::test]
async fn test_print_seed_outputs_skip_seed() {
let data = serde_json::json!({ "skip_seed": true });
let instance = run_step::<PrintSeedOutputs>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_print_seed_outputs_no_recovery_link() {
let data = serde_json::json!({ "skip_seed": false });
let instance = run_step::<PrintSeedOutputs>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_print_seed_outputs_with_recovery_data() {
let data = serde_json::json!({
"skip_seed": false,
"recovery_link": "https://login.test.local/self-service/recovery?flow=abc",
"recovery_code": "123456",
});
let instance = run_step::<PrintSeedOutputs>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_print_seed_outputs_with_empty_recovery() {
let data = serde_json::json!({
"skip_seed": false,
"recovery_link": "",
"recovery_code": "",
});
let instance = run_step::<PrintSeedOutputs>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
// Note: PrintSeedOutputs with a non-empty DKIM key calls k::get_domain()
// which requires a live kube cluster. The DKIM stripping logic is tested
// separately in test_dkim_key_stripping below.
#[tokio::test]
async fn test_print_seed_outputs_empty_dkim_key() {
let data = serde_json::json!({
"skip_seed": false,
"creds": { "messages-dkim-public-key": "" },
});
let instance = run_step::<PrintSeedOutputs>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
// ── Data deserialization ────────────────────────────────────────────
#[test]
fn test_seed_data_recovery_fields() {
let json = serde_json::json!({
"skip_seed": false,
"recovery_link": "https://example.com/recovery",
"recovery_code": "abc123",
"admin_identity_id": "id-uuid-here",
});
let data: SeedData = serde_json::from_value(json).unwrap();
assert_eq!(data.recovery_link.as_deref(), Some("https://example.com/recovery"));
assert_eq!(data.recovery_code.as_deref(), Some("abc123"));
assert_eq!(data.admin_identity_id.as_deref(), Some("id-uuid-here"));
}
#[test]
fn test_seed_data_recovery_fields_default_none() {
let json = serde_json::json!({ "skip_seed": false });
let data: SeedData = serde_json::from_value(json).unwrap();
assert!(data.recovery_link.is_none());
assert!(data.recovery_code.is_none());
assert!(data.admin_identity_id.is_none());
}
#[test]
fn test_dkim_key_stripping() {
let raw = "-----BEGIN PUBLIC KEY-----\nMIIBIjAN\n-----END PUBLIC KEY-----";
let b64_key = strip_pem_headers(raw);
assert_eq!(b64_key, "MIIBIjAN");
}
// ── Pure helper tests ─────────────────────────────────────────────────
#[test]
fn test_strip_pem_headers_standard() {
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjAN\nBgkqhki\n-----END PUBLIC KEY-----";
assert_eq!(strip_pem_headers(pem), "MIIBIjANBgkqhki");
}
#[test]
fn test_strip_pem_headers_rsa_variant() {
let pem = "-----BEGIN RSA PUBLIC KEY-----\nABC123\n-----END RSA PUBLIC KEY-----";
assert_eq!(strip_pem_headers(pem), "ABC123");
}
#[test]
fn test_strip_pem_headers_no_headers() {
assert_eq!(strip_pem_headers("MIIBIjAN"), "MIIBIjAN");
}
#[test]
fn test_strip_pem_headers_empty() {
assert_eq!(strip_pem_headers(""), "");
}
#[test]
fn test_strip_pem_headers_multiline() {
let pem = "-----BEGIN PUBLIC KEY-----\nAAAA\nBBBB\nCCCC\n-----END PUBLIC KEY-----\n";
assert_eq!(strip_pem_headers(pem), "AAAABBBBCCCC");
}
#[test]
fn test_format_dkim_record() {
let record = format_dkim_record("sunbeam.pt", "MIIBIjAN");
assert_eq!(
record,
"default._domainkey.sunbeam.pt TXT \"v=DKIM1; k=rsa; p=MIIBIjAN\""
);
}
#[test]
fn test_format_dkim_record_different_domain() {
let record = format_dkim_record("example.com", "ABC123");
assert!(record.contains("example.com"));
assert!(record.contains("ABC123"));
assert!(record.starts_with("default._domainkey."));
}
#[test]
fn test_admin_email() {
let email = admin_email("sunbeam.pt");
assert_eq!(email, format!("{}@sunbeam.pt", ADMIN_USERNAME));
}
#[test]
fn test_admin_email_different_domain() {
let email = admin_email("example.com");
assert!(email.ends_with("@example.com"));
assert!(email.starts_with(ADMIN_USERNAME));
}
}

View File

@@ -0,0 +1,12 @@
//! Seed workflow steps — each module contains one or more WFE step structs.
pub mod k8s_secrets;
pub mod kratos_admin;
pub mod kv_seeding;
pub mod openbao_init;
pub mod postgres;
pub use k8s_secrets::SyncGiteaAdminPassword;
pub use kratos_admin::{PrintSeedOutputs, SeedKratosAdminIdentity};
pub use openbao_init::{FindOpenBaoPod, InitOrUnsealOpenBao, WaitPodRunning};
pub use postgres::{ConfigureDatabaseEngine, WaitForPostgres};

View File

@@ -0,0 +1,309 @@
//! OpenBao initialization steps: find pod, wait for Running, init/unseal, enable KV.
//!
//! These steps are data-struct-agnostic — they read/write individual JSON fields
//! rather than deserializing a full typed struct. This makes them reusable across
//! the `seed`, `up`, and `verify` workflows.
use std::collections::HashMap;
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::kube as k;
use crate::openbao::BaoClient;
use crate::output::{ok, warn};
use crate::secrets;
use crate::workflows::StepContext;
fn step_err(msg: impl Into<String>) -> wfe_core::WfeError {
wfe_core::WfeError::StepExecution(msg.into())
}
// ── FindOpenBaoPod ──────────────────────────────────────────────────────────
/// Find the OpenBao server pod by label selector.
/// Reads `__ctx` to set kube context. Sets `ob_pod` or `skip_seed=true`.
#[derive(Default)]
pub struct FindOpenBaoPod;
#[async_trait::async_trait]
impl StepBody for FindOpenBaoPod {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let step_ctx: StepContext = serde_json::from_value(
ctx.workflow.data.get("__ctx").cloned().unwrap_or_default()
).map_err(|e| step_err(e.to_string()))?;
k::set_context(&step_ctx.kube_context, &step_ctx.ssh_host);
let client = k::get_client().await.map_err(|e| step_err(e.to_string()))?;
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
kube::Api::namespaced(client.clone(), "data");
let lp = kube::api::ListParams::default()
.labels("app.kubernetes.io/name=openbao,component=server");
let pod_list = pods.list(&lp).await.map_err(|e| step_err(e.to_string()))?;
let ob_pod = pod_list
.items
.first()
.and_then(|p| p.metadata.name.as_deref());
let mut result = ExecutionResult::next();
match ob_pod {
Some(name) => {
ok(&format!("OpenBao ({name})..."));
result.output_data = Some(serde_json::json!({ "ob_pod": name }));
}
None => {
ok("OpenBao pod not found -- skipping.");
result.output_data = Some(serde_json::json!({ "skip_seed": true }));
}
}
Ok(result)
}
}
// ── WaitPodRunning ─────────────────────────────────────────────────────────
/// Wait for the OpenBao pod to reach Running state (up to 5 min).
/// Reads `ob_pod`, `skip_seed`. No-op if skip_seed or ob_pod is absent.
#[derive(Default)]
pub struct WaitPodRunning;
#[async_trait::async_trait]
impl StepBody for WaitPodRunning {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
if ctx.workflow.data.get("skip_seed").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(ExecutionResult::next());
}
let ob_pod = match ctx.workflow.data.get("ob_pod").and_then(|v| v.as_str()) {
Some(p) => p.to_string(),
None => return Ok(ExecutionResult::next()),
};
let _ = secrets::wait_pod_running("data", &ob_pod, 300).await;
Ok(ExecutionResult::next())
}
}
// ── InitOrUnsealOpenBao ─────────────────────────────────────────────────────
/// Port-forward to OpenBao, check seal status, init if needed (storing keys
/// in K8s secret), unseal if needed, enable KV engine.
/// Reads `ob_pod`, `skip_seed`. Sets `ob_port`, `root_token`, or `skip_seed`.
#[derive(Default)]
pub struct InitOrUnsealOpenBao;
#[async_trait::async_trait]
impl StepBody for InitOrUnsealOpenBao {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
if ctx.workflow.data.get("skip_seed").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(ExecutionResult::next());
}
let ob_pod = match ctx.workflow.data.get("ob_pod").and_then(|v| v.as_str()) {
Some(p) => p.to_string(),
None => return Ok(ExecutionResult::next()),
};
// Port-forward with retries
let mut pf = None;
for attempt in 0..10 {
match secrets::port_forward("data", &ob_pod, 8200).await {
Ok(p) => { pf = Some(p); break; }
Err(e) => {
if attempt < 9 {
ok(&format!("Waiting for OpenBao to accept connections (attempt {})...", attempt + 1));
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} else {
return Err(step_err(format!(
"Port-forward to OpenBao failed after 10 attempts: {e}"
)));
}
}
}
}
let pf = pf.unwrap();
let bao_url = format!("http://127.0.0.1:{}", pf.local_port);
let bao = BaoClient::new(&bao_url);
// Wait for API to respond
let mut status = None;
for attempt in 0..30 {
match bao.seal_status().await {
Ok(s) => { status = Some(s); break; }
Err(_) if attempt < 29 => {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
Err(_) => {}
}
}
let mut unseal_key = String::new();
let mut root_token = String::new();
let status = status.unwrap_or_else(|| crate::openbao::SealStatusResponse {
initialized: false, sealed: true, progress: 0, t: 0, n: 0,
});
// Check if truly initialized (not just a placeholder secret)
let mut already_initialized = status.initialized;
if !already_initialized {
if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await {
if !key.is_empty() && key != "placeholder" {
already_initialized = true;
}
}
}
if !already_initialized {
ok("Initializing OpenBao...");
match bao.init(1, 1).await {
Ok(init) => {
unseal_key = init.unseal_keys_b64[0].clone();
root_token = init.root_token.clone();
let mut secret_data = HashMap::new();
secret_data.insert("key".to_string(), unseal_key.clone());
secret_data.insert("root-token".to_string(), root_token.clone());
k::create_secret("data", "openbao-keys", secret_data).await
.map_err(|e| step_err(e.to_string()))?;
ok("Initialized -- keys stored in secret/openbao-keys.");
}
Err(e) => {
warn(&format!("Init failed -- resetting OpenBao storage... ({e})"));
let _ = secrets::delete_resource("data", "pvc", "data-openbao-0").await;
let _ = secrets::delete_resource("data", "pod", &ob_pod).await;
warn("OpenBao storage reset. Run again after the pod restarts.");
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "skip_seed": true }));
return Ok(result);
}
}
} else {
ok("Already initialized.");
if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await {
if key != "placeholder" { unseal_key = key; }
}
if let Ok(token) = k::kube_get_secret_field("data", "openbao-keys", "root-token").await {
if token != "placeholder" { root_token = token; }
}
}
// Unseal if needed
let status = bao.seal_status().await.unwrap_or_else(|_| {
crate::openbao::SealStatusResponse {
initialized: true, sealed: true, progress: 0, t: 0, n: 0,
}
});
if status.sealed && !unseal_key.is_empty() {
ok("Unsealing...");
bao.unseal(&unseal_key).await
.map_err(|e| step_err(format!("Failed to unseal OpenBao: {e}")))?;
}
if root_token.is_empty() {
warn("No root token available -- skipping vault operations.");
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "skip_seed": true }));
return Ok(result);
}
// Enable & tune KV engine
let bao = BaoClient::with_token(&bao_url, &root_token);
ok("Enabling KV engine...");
let _ = bao.enable_secrets_engine("secret", "kv").await;
let _ = bao
.write(
"sys/mounts/secret/tune",
&serde_json::json!({"options": {"version": "2"}}),
)
.await;
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({
"ob_port": pf.local_port,
"root_token": root_token,
}));
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowStatus;
async fn run_step<S: StepBody + Default + 'static>(
data: serde_json::Value,
) -> wfe_core::models::WorkflowInstance {
let host = crate::workflows::host::create_test_host().await.unwrap();
host.register_step::<S>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<S>()
.name("test-step")
.end_workflow()
.build("test-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(&host, "test-wf", 1, data, Duration::from_secs(5))
.await
.unwrap();
host.stop().await;
instance
}
#[tokio::test]
async fn test_wait_pod_running_skip_seed() {
let data = serde_json::json!({ "skip_seed": true });
let instance = run_step::<WaitPodRunning>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_wait_pod_running_no_ob_pod() {
let data = serde_json::json!({ "skip_seed": false });
let instance = run_step::<WaitPodRunning>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_wait_pod_running_ob_pod_none_explicit() {
let data = serde_json::json!({ "skip_seed": false, "ob_pod": null });
let instance = run_step::<WaitPodRunning>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_init_or_unseal_skip_seed() {
let data = serde_json::json!({ "skip_seed": true });
let instance = run_step::<InitOrUnsealOpenBao>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_init_or_unseal_no_ob_pod() {
let data = serde_json::json!({ "skip_seed": false });
let instance = run_step::<InitOrUnsealOpenBao>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_init_or_unseal_ob_pod_none() {
let data = serde_json::json!({ "skip_seed": false, "ob_pod": null });
let instance = run_step::<InitOrUnsealOpenBao>(data).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
}

View File

@@ -0,0 +1,388 @@
//! Up workflow definition — phased deployment with parallel branches.
use serde_json::json;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowDefinition;
use super::steps;
use crate::workflows::primitives::{
ApplyManifest, CollectCredentials, CreateK8sSecret, CreatePGDatabase, CreatePGRole,
EnableVaultAuth, EnsureNamespace, EnsureOpenSearchML, InjectOpenSearchModelId,
SeedKVPath, WaitForRollout, WriteKVPath,
WriteVaultAuthConfig, WriteVaultPolicy, WriteVaultRole,
};
use crate::workflows::primitives::kv_service_configs;
use crate::workflows::seed::steps::postgres::pg_db_map;
/// Build the up workflow definition.
pub fn build() -> WorkflowDefinition {
WorkflowBuilder::<serde_json::Value>::new()
// ── Phase 1: Infrastructure ────────────────────────────────────
.start_with::<steps::EnsureCilium>()
.name("ensure-cilium")
.parallel(|p| p
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-longhorn",
Some(json!({"namespace": "longhorn-system"})));
})
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-monitoring",
Some(json!({"namespace": "monitoring"})));
})
)
.then::<ApplyManifest>()
.name("apply-data")
.config(json!({"namespace": "data"}))
.parallel(|p| p
.branch(|b| {
b.add_step_typed::<steps::EnsureBuildKit>("ensure-buildkit", None);
})
.branch(|b| {
let id0 = b.add_step_typed::<steps::EnsureTLSCert>("ensure-tls-cert", None);
let id1 = b.add_step_typed::<steps::EnsureTLSSecret>("ensure-tls-secret", None);
let id2 = b.add_step_typed::<ApplyManifest>("apply-cert-manager",
Some(json!({"namespace": "cert-manager"})));
b.wire_outcome(id0, id1, None);
b.wire_outcome(id1, id2, None);
})
)
// ── Phase 2: OpenBao init (sequential) ────────────────────────
.then::<steps::FindOpenBaoPod>()
.name("find-openbao-pod")
.then::<steps::WaitPodRunning>()
.name("wait-pod-running")
.then::<steps::InitOrUnsealOpenBao>()
.name("init-or-unseal-openbao")
// ── Phase 3: KV seeding (parallel per-service) ────────────────
.parallel(|p| {
let mut p = p;
for cfg in kv_service_configs::all_service_configs() {
let service = cfg["service"].as_str().unwrap().to_string();
p = p.branch(|b| {
let seed_id = b.add_step_typed::<SeedKVPath>(
&format!("seed-{service}"), Some(cfg.clone()));
let write_id = b.add_step_typed::<WriteKVPath>(
&format!("write-{service}"), Some(json!({"service": &service})));
b.wire_outcome(seed_id, write_id, None);
});
}
// kratos-admin depends on seaweedfs (from_creds reference)
p.branch(|b| {
let seed_id = b.add_step_typed::<SeedKVPath>(
"seed-kratos-admin", Some(kv_service_configs::kratos_admin_config()));
let write_id = b.add_step_typed::<WriteKVPath>(
"write-kratos-admin", Some(json!({"service": "kratos-admin"})));
b.wire_outcome(seed_id, write_id, None);
})
})
.then::<CollectCredentials>()
.name("collect-credentials")
// ── Phase 3b: Vault auth (4 atomic steps) ──────────────────
.then::<EnableVaultAuth>()
.name("enable-k8s-auth")
.config(json!({"mount": "kubernetes", "type": "kubernetes"}))
.then::<WriteVaultAuthConfig>()
.name("write-k8s-auth-config")
.config(json!({"mount": "kubernetes", "config": {
"kubernetes_host": "https://kubernetes.default.svc.cluster.local"
}}))
.then::<WriteVaultPolicy>()
.name("write-vso-policy")
.config(json!({"name": "vso-reader", "hcl": concat!(
"path \"secret/data/*\" { capabilities = [\"read\"] }\n",
"path \"secret/metadata/*\" { capabilities = [\"read\", \"list\"] }\n",
"path \"database/static-creds/*\" { capabilities = [\"read\"] }\n",
)}))
.then::<WriteVaultRole>()
.name("write-vso-role")
.config(json!({"mount": "kubernetes", "role": "vso", "config": {
"bound_service_account_names": "default",
"bound_service_account_namespaces": "ory,devtools,storage,lasuite,stalwart,matrix,media,data,monitoring,cert-manager",
"policies": "vso-reader",
"ttl": "1h"
}}))
// ── Phase 4: PostgreSQL ───────────────────────────────────────
.then::<steps::WaitForPostgres>()
.name("wait-for-postgres")
.parallel(|p| {
let db_map = pg_db_map();
let mut p = p;
for (user, db) in &db_map {
p = p.branch(|b| {
let role_id = b.add_step_typed::<CreatePGRole>(
&format!("pg-role-{user}"),
Some(json!({"username": user})),
);
let db_id = b.add_step_typed::<CreatePGDatabase>(
&format!("pg-db-{db}"),
Some(json!({"dbname": db, "owner": user})),
);
b.wire_outcome(role_id, db_id, None);
});
}
p
})
.then::<steps::ConfigureDatabaseEngine>()
.name("configure-database-engine")
// ── Phase 5: Platform manifests ───────────────────────────────
.then::<ApplyManifest>()
.name("apply-vso")
.config(json!({"namespace": "vault-secrets-operator"}))
.parallel(|p| p
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-ingress",
Some(json!({"namespace": "ingress"})));
})
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-ory",
Some(json!({"namespace": "ory"})));
})
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-devtools",
Some(json!({"namespace": "devtools"})));
})
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-storage",
Some(json!({"namespace": "storage"})));
})
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-media",
Some(json!({"namespace": "media"})));
})
)
// ── Phase 6: K8s secrets (parallel by namespace) ──────────────
.parallel(|p| p
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-ory",
Some(json!({"namespace": "ory"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-hydra",
Some(json!({"namespace":"ory","name":"hydra","data":{
"secretsSystem":"hydra-system-secret",
"secretsCookie":"hydra-cookie-secret",
"pairwise-salt":"hydra-pairwise-salt"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-kratos-app",
Some(json!({"namespace":"ory","name":"kratos-app-secrets","data":{
"secretsDefault":"kratos-secrets-default",
"secretsCookie":"kratos-secrets-cookie"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
})
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-devtools",
Some(json!({"namespace": "devtools"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-gitea-s3",
Some(json!({"namespace":"devtools","name":"gitea-s3-credentials","data":{
"access-key":"s3-access-key",
"secret-key":"s3-secret-key"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-gitea-admin",
Some(json!({"namespace":"devtools","name":"gitea-admin-credentials","data":{
"username":"literal:gitea_admin",
"password":"gitea-admin-password"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
})
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-storage",
Some(json!({"namespace": "storage"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-seaweedfs-s3-creds",
Some(json!({"namespace":"storage","name":"seaweedfs-s3-credentials","data":{
"S3_ACCESS_KEY":"s3-access-key",
"S3_SECRET_KEY":"s3-secret-key"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-seaweedfs-s3-json",
Some(json!({"namespace":"storage","name":"seaweedfs-s3-json","data":{
"s3.json":"s3_json"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
})
.branch(|b| {
let ns = b.add_step_typed::<EnsureNamespace>("ensure-ns-lasuite",
Some(json!({"namespace": "lasuite"})));
let s1 = b.add_step_typed::<CreateK8sSecret>("secret-lasuite-s3",
Some(json!({"namespace":"lasuite","name":"seaweedfs-s3-credentials","data":{
"S3_ACCESS_KEY":"s3-access-key",
"S3_SECRET_KEY":"s3-secret-key"
}})));
let s2 = b.add_step_typed::<CreateK8sSecret>("secret-hive-oidc",
Some(json!({"namespace":"lasuite","name":"hive-oidc","data":{
"client-id":"hive-oidc-client-id",
"client-secret":"hive-oidc-client-secret"
}})));
let s3 = b.add_step_typed::<CreateK8sSecret>("secret-people-django",
Some(json!({"namespace":"lasuite","name":"people-django-secret","data":{
"DJANGO_SECRET_KEY":"people-django-secret"
}})));
b.wire_outcome(ns, s1, None);
b.wire_outcome(s1, s2, None);
b.wire_outcome(s2, s3, None);
})
.branch(|b| {
b.add_step_typed::<EnsureNamespace>("ensure-ns-matrix",
Some(json!({"namespace": "matrix"})));
})
.branch(|b| {
b.add_step_typed::<EnsureNamespace>("ensure-ns-media",
Some(json!({"namespace": "media"})));
})
.branch(|b| {
b.add_step_typed::<EnsureNamespace>("ensure-ns-monitoring",
Some(json!({"namespace": "monitoring"})));
})
)
.then::<steps::SyncGiteaAdminPassword>()
.name("sync-gitea-admin-password")
.then::<steps::BootstrapGitea>()
.name("bootstrap-gitea")
// ── Phase 7: Application manifests ────────────────────────────
.parallel(|p| p
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-lasuite",
Some(json!({"namespace": "lasuite"})));
})
.branch(|b| {
b.add_step_typed::<ApplyManifest>("apply-matrix",
Some(json!({"namespace": "matrix"})));
})
)
// ── Phase 8: Core rollouts + OpenSearch ML (parallel) ─────────
.parallel(|p| p
.branch(|b| {
b.add_step_typed::<WaitForRollout>("wait-valkey",
Some(json!({"namespace": "data", "deployment": "valkey", "timeout_secs": 120})));
})
.branch(|b| {
b.add_step_typed::<WaitForRollout>("wait-kratos",
Some(json!({"namespace": "ory", "deployment": "kratos", "timeout_secs": 120})));
})
.branch(|b| {
b.add_step_typed::<WaitForRollout>("wait-hydra",
Some(json!({"namespace": "ory", "deployment": "hydra", "timeout_secs": 120})));
})
.branch(|b| {
// OpenSearch ML model download/deploy — can take 10+ min on first run.
// Runs alongside rollout waits so it doesn't block the pipeline.
b.add_step_typed::<EnsureOpenSearchML>("ensure-opensearch-ml", None);
})
)
.then::<InjectOpenSearchModelId>()
.name("inject-opensearch-model-id")
.then::<steps::PrintURLs>()
.name("print-urls")
.end_workflow()
.build("up", 2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_returns_valid_definition() {
let def = build();
assert_eq!(def.id, "up");
assert_eq!(def.version, 2);
assert!(def.steps.len() > 20, "expected >20 steps, got {}", def.steps.len());
}
#[test]
fn test_first_step_is_ensure_cilium() {
let def = build();
assert_eq!(def.steps[0].name, Some("ensure-cilium".into()));
assert!(def.steps[0].step_type.contains("EnsureCilium"));
}
#[test]
fn test_last_step_is_print_urls() {
let def = build();
let last = def.steps.last().unwrap();
assert_eq!(last.name, Some("print-urls".into()));
assert!(last.step_type.contains("PrintURLs"));
}
#[test]
fn test_apply_manifest_steps_have_config() {
let def = build();
let apply_steps: Vec<_> = def.steps.iter()
.filter(|s| s.step_type.contains("ApplyManifest"))
.collect();
assert!(!apply_steps.is_empty(), "should have ApplyManifest steps");
for s in &apply_steps {
let config = s.step_config.as_ref()
.unwrap_or_else(|| panic!("ApplyManifest step {:?} missing config", s.name));
assert!(config.get("namespace").is_some(),
"ApplyManifest step {:?} missing namespace in config", s.name);
}
}
#[test]
fn test_wait_for_rollout_steps_have_config() {
let def = build();
let rollout_steps: Vec<_> = def.steps.iter()
.filter(|s| s.step_type.contains("WaitForRollout"))
.collect();
assert_eq!(rollout_steps.len(), 3, "should have 3 WaitForRollout steps");
for s in &rollout_steps {
let config = s.step_config.as_ref().unwrap();
assert!(config.get("namespace").is_some());
assert!(config.get("deployment").is_some());
}
}
#[test]
fn test_has_parallel_containers() {
let def = build();
let seq_steps: Vec<_> = def.steps.iter()
.filter(|s| s.step_type.contains("SequenceStep"))
.collect();
assert!(seq_steps.len() >= 4, "expected >=4 parallel blocks, got {}", seq_steps.len());
for s in &seq_steps {
assert!(!s.children.is_empty(), "parallel container should have children");
}
}
#[test]
fn test_non_container_steps_have_names() {
let def = build();
for s in &def.steps {
// SequenceStep containers are auto-generated by .parallel()
if s.step_type.contains("SequenceStep") {
continue;
}
assert!(s.name.is_some(), "step {} ({}) has no name", s.id, s.step_type);
}
}
#[test]
fn test_cert_branch_has_chained_outcomes() {
let def = build();
let tls_cert = def.steps.iter()
.find(|s| s.name.as_deref() == Some("ensure-tls-cert"))
.expect("should have ensure-tls-cert step");
assert!(!tls_cert.outcomes.is_empty(), "ensure-tls-cert should wire to ensure-tls-secret");
let tls_secret = def.steps.iter()
.find(|s| s.name.as_deref() == Some("ensure-tls-secret"))
.expect("should have ensure-tls-secret step");
assert!(!tls_secret.outcomes.is_empty(), "ensure-tls-secret should wire to apply-cert-manager");
}
}

106
src/workflows/up/mod.rs Normal file
View File

@@ -0,0 +1,106 @@
//! Up workflow — orchestrates full cluster bring-up as composable steps.
pub mod definition;
pub mod steps;
use crate::output;
/// Register all up workflow steps and the workflow definition with a host.
pub async fn register(host: &wfe::WorkflowHost) {
// Primitive steps (config-driven, reusable)
host.register_step::<crate::workflows::primitives::ApplyManifest>().await;
host.register_step::<crate::workflows::primitives::WaitForRollout>().await;
host.register_step::<crate::workflows::primitives::CreatePGRole>().await;
host.register_step::<crate::workflows::primitives::CreatePGDatabase>().await;
host.register_step::<crate::workflows::primitives::EnsureNamespace>().await;
host.register_step::<crate::workflows::primitives::CreateK8sSecret>().await;
host.register_step::<crate::workflows::primitives::EnableVaultAuth>().await;
host.register_step::<crate::workflows::primitives::WriteVaultAuthConfig>().await;
host.register_step::<crate::workflows::primitives::WriteVaultPolicy>().await;
host.register_step::<crate::workflows::primitives::WriteVaultRole>().await;
host.register_step::<crate::workflows::primitives::SeedKVPath>().await;
host.register_step::<crate::workflows::primitives::WriteKVPath>().await;
host.register_step::<crate::workflows::primitives::CollectCredentials>().await;
host.register_step::<crate::workflows::primitives::EnsureOpenSearchML>().await;
host.register_step::<crate::workflows::primitives::InjectOpenSearchModelId>().await;
// Steps unique to up
host.register_step::<steps::EnsureCilium>().await;
host.register_step::<steps::EnsureBuildKit>().await;
host.register_step::<steps::EnsureTLSCert>().await;
host.register_step::<steps::EnsureTLSSecret>().await;
host.register_step::<steps::BootstrapGitea>().await;
host.register_step::<steps::PrintURLs>().await;
// Steps shared from seed workflow
host.register_step::<steps::FindOpenBaoPod>().await;
host.register_step::<steps::WaitPodRunning>().await;
host.register_step::<steps::InitOrUnsealOpenBao>().await;
host.register_step::<steps::WaitForPostgres>().await;
host.register_step::<steps::ConfigureDatabaseEngine>().await;
host.register_step::<steps::SyncGiteaAdminPassword>().await;
// Register workflow definition
host.register_workflow_definition(definition::build()).await;
}
/// Print a summary of the completed up workflow.
pub fn print_summary(instance: &wfe_core::models::WorkflowInstance) {
output::step("Up workflow summary:");
for ep in &instance.execution_pointers {
let fallback = format!("step-{}", ep.step_id);
let name = ep.step_name.as_deref().unwrap_or(&fallback);
let status = format!("{:?}", ep.status);
let duration = match (ep.start_time, ep.end_time) {
(Some(start), Some(end)) => {
let d = end - start;
format!("{}ms", d.num_milliseconds())
}
_ => "-".to_string(),
};
output::ok(&format!(" {name:<40} {status:<12} {duration}"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_register_all_steps_and_definition() {
let host = crate::workflows::host::create_test_host().await.unwrap();
register(&host).await;
let def = definition::build();
assert!(def.steps.len() > 20);
assert_eq!(def.id, "up");
host.stop().await;
}
#[tokio::test]
async fn test_print_summary_with_missing_step_names() {
let mut instance =
wfe_core::models::WorkflowInstance::new("test", 1, serde_json::json!({}));
let mut ep = wfe_core::models::ExecutionPointer::new(0);
ep.step_name = None;
ep.status = wfe_core::models::PointerStatus::Complete;
ep.start_time = Some(chrono::Utc::now());
ep.end_time = Some(chrono::Utc::now());
instance.execution_pointers.push(ep);
print_summary(&instance);
}
#[tokio::test]
async fn test_print_summary_with_missing_times() {
let mut instance =
wfe_core::models::WorkflowInstance::new("test", 1, serde_json::json!({}));
let mut ep = wfe_core::models::ExecutionPointer::new(0);
ep.step_name = Some("test-step".to_string());
ep.status = wfe_core::models::PointerStatus::Complete;
ep.start_time = None;
ep.end_time = None;
instance.execution_pointers.push(ep);
print_summary(&instance);
}
}

View File

@@ -0,0 +1,227 @@
//! Certificate steps: TLS cert generation, TLS secret, cert-manager install.
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::kube as k;
use crate::output::{ok, step};
use crate::workflows::data::UpData;
fn secrets_dir() -> std::path::PathBuf {
crate::config::get_infra_dir()
.join("secrets")
.join("local")
}
// ── EnsureTLSCert ───────────────────────────────────────────────────────────
/// Generate a self-signed wildcard TLS certificate if one doesn't exist.
#[derive(Default)]
pub struct EnsureTLSCert;
#[async_trait::async_trait]
impl StepBody for EnsureTLSCert {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data: UpData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let domain = resolve_domain(&data)?;
step("TLS certificate...");
let dir = secrets_dir();
let cert_path = dir.join("tls.crt");
let key_path = dir.join("tls.key");
if cert_path.exists() {
ok(&format!("Cert exists. Domain: {domain}"));
return Ok(ExecutionResult::next());
}
ok(&format!("Generating wildcard cert for *.{domain}..."));
std::fs::create_dir_all(&dir).map_err(|e| {
wfe_core::WfeError::StepExecution(format!(
"Failed to create secrets dir {}: {e}",
dir.display()
))
})?;
let subject_alt_names = vec![format!("*.{domain}")];
let mut params = rcgen::CertificateParams::new(subject_alt_names)
.map_err(|e| {
wfe_core::WfeError::StepExecution(format!(
"Failed to create certificate params: {e}"
))
})?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, format!("*.{domain}"));
let key_pair = rcgen::KeyPair::generate().map_err(|e| {
wfe_core::WfeError::StepExecution(format!("Failed to generate key pair: {e}"))
})?;
let cert = params.self_signed(&key_pair).map_err(|e| {
wfe_core::WfeError::StepExecution(format!(
"Failed to generate self-signed certificate: {e}"
))
})?;
std::fs::write(&cert_path, cert.pem()).map_err(|e| {
wfe_core::WfeError::StepExecution(format!(
"Failed to write {}: {e}",
cert_path.display()
))
})?;
std::fs::write(&key_path, key_pair.serialize_pem()).map_err(|e| {
wfe_core::WfeError::StepExecution(format!(
"Failed to write {}: {e}",
key_path.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| {
wfe_core::WfeError::StepExecution(format!(
"Failed to set key permissions: {e}"
))
})?;
}
ok(&format!("Cert generated. Domain: {domain}"));
Ok(ExecutionResult::next())
}
}
// ── EnsureTLSSecret ─────────────────────────────────────────────────────────
/// Apply the TLS secret to the ingress namespace.
#[derive(Default)]
pub struct EnsureTLSSecret;
#[async_trait::async_trait]
impl StepBody for EnsureTLSSecret {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let _data: UpData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
step("TLS secret...");
k::ensure_ns("ingress")
.await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let dir = secrets_dir();
let cert_pem = std::fs::read_to_string(dir.join("tls.crt")).map_err(|e| {
wfe_core::WfeError::StepExecution(format!("Failed to read tls.crt: {e}"))
})?;
let key_pem = std::fs::read_to_string(dir.join("tls.key")).map_err(|e| {
wfe_core::WfeError::StepExecution(format!("Failed to read tls.key: {e}"))
})?;
let client = k::get_client()
.await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let api: kube::api::Api<k8s_openapi::api::core::v1::Secret> =
kube::api::Api::namespaced(client.clone(), "ingress");
let b64_cert = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
cert_pem.as_bytes(),
);
let b64_key = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
key_pem.as_bytes(),
);
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "pingora-tls",
"namespace": "ingress",
},
"type": "kubernetes.io/tls",
"data": {
"tls.crt": b64_cert,
"tls.key": b64_key,
},
});
let pp = kube::api::PatchParams::apply("sunbeam").force();
api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj))
.await
.map_err(|e| {
wfe_core::WfeError::StepExecution(format!("Failed to create TLS secret: {e}"))
})?;
ok("Done.");
Ok(ExecutionResult::next())
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn resolve_domain(data: &UpData) -> wfe_core::Result<String> {
if !data.domain.is_empty() {
return Ok(data.domain.clone());
}
if let Some(ctx) = &data.ctx {
if !ctx.domain.is_empty() {
return Ok(ctx.domain.clone());
}
}
Err(wfe_core::WfeError::StepExecution(
"domain not resolved".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::CERT_MANAGER_URL;
#[test]
fn secrets_dir_ends_with_secrets_local() {
let dir = secrets_dir();
assert!(
dir.ends_with("secrets/local"),
"secrets_dir() should end with secrets/local, got: {}",
dir.display()
);
}
#[test]
fn cert_manager_url_points_to_github_release() {
assert!(CERT_MANAGER_URL.starts_with("https://github.com/cert-manager/cert-manager/"));
assert!(CERT_MANAGER_URL.contains("/releases/download/"));
assert!(CERT_MANAGER_URL.ends_with(".yaml"));
}
#[test]
fn cert_manager_url_has_version() {
assert!(
CERT_MANAGER_URL.contains("/v1."),
"CERT_MANAGER_URL should reference a v1.x release"
);
}
#[test]
fn ensure_tls_cert_is_default() {
let _ = EnsureTLSCert::default();
}
#[test]
fn ensure_tls_secret_is_default() {
let _ = EnsureTLSSecret::default();
}
}

View File

@@ -0,0 +1,4 @@
//! Database steps unique to up.
//!
//! WaitForPostgres and ConfigureDatabaseEngine are shared from seed::steps::postgres.
//! ApplyVSO is now handled by the ApplyManifest primitive.

View File

@@ -0,0 +1,90 @@
//! Finalize steps: print URLs.
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::constants::GITEA_ADMIN_USER;
use crate::workflows::data::UpData;
fn resolve_domain(data: &UpData) -> String {
if !data.domain.is_empty() {
return data.domain.clone();
}
if let Some(ctx) = &data.ctx {
if !ctx.domain.is_empty() {
return ctx.domain.clone();
}
}
String::new()
}
/// Print service URLs.
#[derive(Default)]
pub struct PrintURLs;
#[async_trait::async_trait]
impl StepBody for PrintURLs {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data: UpData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let domain = resolve_domain(&data);
let sep = "\u{2500}".repeat(60);
println!("\n{sep}");
println!(" Stack is up. Domain: {domain}");
println!("{sep}");
let urls: &[(&str, String)] = &[
("Auth", format!("https://auth.{domain}/")),
("Docs", format!("https://docs.{domain}/")),
("Meet", format!("https://meet.{domain}/")),
("Drive", format!("https://drive.{domain}/")),
("Chat", format!("https://chat.{domain}/")),
("Mail", format!("https://mail.{domain}/")),
("People", format!("https://people.{domain}/")),
(
"Gitea",
format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / <from openbao>)"
),
),
];
for (name, url) in urls {
println!(" {name:<10} {url}");
}
println!();
println!(" OpenBao UI:");
println!(" kubectl --context=sunbeam -n data port-forward svc/openbao 8200:8200");
println!(" http://localhost:8200");
println!(
" token: kubectl --context=sunbeam -n data get secret openbao-keys \
-o jsonpath='{{.data.root-token}}' | base64 -d"
);
println!("{sep}\n");
Ok(ExecutionResult::next())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn print_urls_is_default() { let _ = PrintURLs::default(); }
#[test]
fn resolve_domain_with_domain_set() {
let data = UpData {
domain: "sunbeam.pt".into(),
..Default::default()
};
assert_eq!(resolve_domain(&data), "sunbeam.pt");
}
}

View File

@@ -0,0 +1,117 @@
//! Infrastructure steps: Cilium check, buildkit check.
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::kube as k;
use crate::output::{ok, step, warn};
use crate::workflows::data::UpData;
// ── EnsureCilium ────────────────────────────────────────────────────────────
/// Verify Cilium CNI pods are running in kube-system. Warn if missing, don't fail.
#[derive(Default)]
pub struct EnsureCilium;
#[async_trait::async_trait]
impl StepBody for EnsureCilium {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data: UpData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let step_ctx = data
.ctx
.as_ref()
.ok_or_else(|| wfe_core::WfeError::StepExecution("missing __ctx".into()))?;
// Initialize kube context for the rest of the workflow
k::set_context(&step_ctx.kube_context, &step_ctx.ssh_host);
step("Cilium CNI...");
let client = k::get_client()
.await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let found = check_cilium_pods(client, "kube-system").await
|| check_cilium_pods(client, "cilium-system").await;
if found {
ok("Cilium is healthy.");
} else {
warn("Cilium pods not found. CNI should be installed at the infrastructure level.");
warn("Continuing anyway -- networking may not work correctly.");
}
// Resolve domain if empty and store in workflow data
let mut result = ExecutionResult::next();
if data.domain.is_empty() {
let domain = k::get_domain()
.await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
result.output_data = Some(serde_json::json!({ "domain": domain }));
}
Ok(result)
}
}
async fn check_cilium_pods(client: &kube::Client, ns: &str) -> bool {
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
kube::Api::namespaced(client.clone(), ns);
let lp = kube::api::ListParams::default().labels("k8s-app=cilium");
match pods.list(&lp).await {
Ok(list) => !list.items.is_empty(),
Err(_) => false,
}
}
// ── EnsureBuildKit ──────────────────────────────────────────────────────────
/// Check buildkit pods, warn if not present.
#[derive(Default)]
pub struct EnsureBuildKit;
#[async_trait::async_trait]
impl StepBody for EnsureBuildKit {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let _data: UpData = serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
step("BuildKit...");
let client = k::get_client()
.await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
kube::Api::namespaced(client.clone(), "buildkit");
let lp = kube::api::ListParams::default();
match pods.list(&lp).await {
Ok(list) if !list.items.is_empty() => ok("BuildKit is present."),
_ => warn("BuildKit pods not found -- image builds may not work."),
}
Ok(ExecutionResult::next())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_cilium_is_default() {
let _ = EnsureCilium::default();
}
#[test]
fn ensure_buildkit_is_default() {
let _ = EnsureBuildKit::default();
}
}

View File

@@ -0,0 +1,22 @@
//! Up workflow steps — each module contains one or more WFE step structs.
pub mod certificates;
pub mod database;
pub mod finalize;
pub mod infrastructure;
pub mod platform;
pub mod vault;
// Steps unique to the up workflow
pub use certificates::{EnsureTLSCert, EnsureTLSSecret};
pub use finalize::PrintURLs;
pub use infrastructure::{EnsureBuildKit, EnsureCilium};
pub use platform::BootstrapGitea;
// Steps shared from seed workflow (data-struct-agnostic, reusable)
pub use crate::workflows::seed::steps::{
FindOpenBaoPod, WaitPodRunning, InitOrUnsealOpenBao,
WaitForPostgres,
ConfigureDatabaseEngine,
SyncGiteaAdminPassword,
};

View File

@@ -0,0 +1,31 @@
//! Platform steps: Gitea bootstrap.
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::output::step;
/// Run Gitea bootstrap (repos, webhooks, etc.).
#[derive(Default)]
pub struct BootstrapGitea;
#[async_trait::async_trait]
impl StepBody for BootstrapGitea {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
step("Gitea bootstrap...");
crate::gitea::cmd_bootstrap().await
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))?;
Ok(ExecutionResult::next())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bootstrap_gitea_is_default() { let _ = BootstrapGitea::default(); }
}

View File

@@ -0,0 +1,5 @@
//! Vault steps unique to up workflow.
//!
//! OpenBao init/unseal, KV seeding, K8s auth, and KV writing are all shared
//! from seed::steps (openbao_init, kv_seeding). This file is kept for the
//! module structure but currently has no unique steps.

View File

@@ -0,0 +1,79 @@
//! Verify workflow definition — VSO ↔ OpenBao E2E verification.
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowDefinition;
use super::steps;
/// Build the verify workflow definition.
///
/// Steps execute sequentially:
/// 1. Find OpenBao pod
/// 2. Get root token from K8s secret
/// 3. Write sentinel value to OpenBao
/// 4. Apply VaultAuth CRD
/// 5. Apply VaultStaticSecret CRD
/// 6. Wait for VSO to sync
/// 7. Check K8s Secret value matches sentinel
/// 8. Clean up test resources
/// 9. Print result
pub fn build() -> WorkflowDefinition {
WorkflowBuilder::<serde_json::Value>::new()
.start_with::<steps::FindOpenBaoPod>()
.name("find-openbao-pod")
.then::<steps::GetRootToken>()
.name("get-root-token")
.then::<steps::WriteSentinel>()
.name("write-sentinel")
.then::<steps::ApplyVaultAuth>()
.name("apply-vault-auth")
.then::<steps::ApplyVaultStaticSecret>()
.name("apply-vault-static-secret")
.then::<steps::WaitForSync>()
.name("wait-for-sync")
.then::<steps::CheckSecretValue>()
.name("check-secret-value")
.then::<steps::Cleanup>()
.name("cleanup")
.then::<steps::PrintResult>()
.name("print-result")
.end_workflow()
.build("verify", 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_returns_valid_definition() {
let def = build();
assert_eq!(def.id, "verify");
assert_eq!(def.version, 1);
assert_eq!(def.steps.len(), 9);
}
#[test]
fn test_build_step_names() {
let def = build();
let names: Vec<Option<&str>> = def
.steps
.iter()
.map(|s| s.name.as_deref())
.collect();
assert_eq!(
names,
vec![
Some("find-openbao-pod"),
Some("get-root-token"),
Some("write-sentinel"),
Some("apply-vault-auth"),
Some("apply-vault-static-secret"),
Some("wait-for-sync"),
Some("check-secret-value"),
Some("cleanup"),
Some("print-result"),
]
);
}
}

View File

@@ -0,0 +1,39 @@
//! Verify workflow — VSO ↔ OpenBao end-to-end verification.
pub mod definition;
pub mod steps;
use crate::output;
/// Register all verify workflow steps and the workflow definition with a host.
pub async fn register(host: &wfe::WorkflowHost) {
host.register_step::<steps::FindOpenBaoPod>().await;
host.register_step::<steps::GetRootToken>().await;
host.register_step::<steps::WriteSentinel>().await;
host.register_step::<steps::ApplyVaultAuth>().await;
host.register_step::<steps::ApplyVaultStaticSecret>().await;
host.register_step::<steps::WaitForSync>().await;
host.register_step::<steps::CheckSecretValue>().await;
host.register_step::<steps::Cleanup>().await;
host.register_step::<steps::PrintResult>().await;
host.register_workflow_definition(definition::build()).await;
}
/// Print a summary of the completed verify workflow.
pub fn print_summary(instance: &wfe_core::models::WorkflowInstance) {
output::step("Verify workflow summary:");
for ep in &instance.execution_pointers {
let fallback = format!("step-{}", ep.step_id);
let name = ep.step_name.as_deref().unwrap_or(&fallback);
let status = format!("{:?}", ep.status);
let duration = match (ep.start_time, ep.end_time) {
(Some(start), Some(end)) => {
let d = end - start;
format!("{}ms", d.num_milliseconds())
}
_ => "-".to_string(),
};
output::ok(&format!(" {name:<40} {status:<12} {duration}"));
}
}

View File

@@ -0,0 +1,15 @@
//! Verify workflow steps — VSO ↔ OpenBao end-to-end verification.
mod verify;
pub use verify::{
FindOpenBaoPod,
GetRootToken,
WriteSentinel,
ApplyVaultAuth,
ApplyVaultStaticSecret,
WaitForSync,
CheckSecretValue,
Cleanup,
PrintResult,
};

View File

@@ -0,0 +1,411 @@
//! Steps for the verify workflow — VSO ↔ OpenBao E2E verification.
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::kube as k;
use crate::openbao::BaoClient;
use crate::output::{ok, warn};
use crate::secrets;
use crate::workflows::data::VerifyData;
const TEST_NS: &str = "ory";
const TEST_NAME: &str = "vso-verify";
fn load_data(ctx: &StepExecutionContext<'_>) -> wfe_core::Result<VerifyData> {
serde_json::from_value(ctx.workflow.data.clone())
.map_err(|e| wfe_core::WfeError::StepExecution(e.to_string()))
}
fn step_err(msg: impl Into<String>) -> wfe_core::WfeError {
wfe_core::WfeError::StepExecution(msg.into())
}
// ── FindOpenBaoPod ─────────────────────────────────────────────────────────
/// Find the OpenBao server pod by label selector.
#[derive(Default)]
pub struct FindOpenBaoPod;
#[async_trait::async_trait]
impl StepBody for FindOpenBaoPod {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let step_ctx = data.ctx.as_ref()
.ok_or_else(|| step_err("missing __ctx in workflow data"))?;
k::set_context(&step_ctx.kube_context, &step_ctx.ssh_host);
let client = k::get_client().await.map_err(|e| step_err(e.to_string()))?;
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
kube::Api::namespaced(client.clone(), "data");
let lp = kube::api::ListParams::default()
.labels("app.kubernetes.io/name=openbao,component=server");
let pod_list = pods.list(&lp).await.map_err(|e| step_err(e.to_string()))?;
let ob_pod = pod_list
.items
.first()
.and_then(|p| p.metadata.name.as_deref())
.ok_or_else(|| step_err("OpenBao pod not found -- run full bring-up first"))?;
ok(&format!("OpenBao pod: {ob_pod}"));
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "ob_pod": ob_pod }));
Ok(result)
}
}
// ── GetRootToken ───────────────────────────────────────────────────────────
/// Read the root token from the openbao-keys K8s secret.
#[derive(Default)]
pub struct GetRootToken;
#[async_trait::async_trait]
impl StepBody for GetRootToken {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let root_token = k::kube_get_secret_field("data", "openbao-keys", "root-token")
.await
.map_err(|e| step_err(format!("Could not read openbao-keys secret: {e}")))?;
ok("Root token retrieved.");
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "root_token": root_token }));
Ok(result)
}
}
// ── WriteSentinel ──────────────────────────────────────────────────────────
/// Write a random test sentinel value to OpenBao secret/vso-test.
#[derive(Default)]
pub struct WriteSentinel;
#[async_trait::async_trait]
impl StepBody for WriteSentinel {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let ob_pod = data.ob_pod.as_deref()
.ok_or_else(|| step_err("ob_pod not set"))?;
let root_token = data.root_token.as_deref()
.ok_or_else(|| step_err("root_token not set"))?;
let pf = secrets::port_forward("data", ob_pod, 8200).await
.map_err(|e| step_err(e.to_string()))?;
let bao = BaoClient::with_token(
&format!("http://127.0.0.1:{}", pf.local_port),
root_token,
);
let test_value = secrets::rand_token_n(16);
ok("Writing test sentinel to OpenBao secret/vso-test...");
let mut kv_data = std::collections::HashMap::new();
kv_data.insert("test-key".to_string(), test_value.clone());
bao.kv_put("secret", "vso-test", &kv_data).await
.map_err(|e| step_err(e.to_string()))?;
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "test_value": test_value }));
Ok(result)
}
}
// ── ApplyVaultAuth ─────────────────────────────────────────────────────────
/// Create the VaultAuth CRD for the test.
#[derive(Default)]
pub struct ApplyVaultAuth;
#[async_trait::async_trait]
impl StepBody for ApplyVaultAuth {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
ok(&format!("Creating VaultAuth {TEST_NS}/{TEST_NAME}..."));
k::kube_apply(&format!(
r#"
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: {TEST_NAME}
namespace: {TEST_NS}
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: vso
serviceAccount: default
"#
))
.await
.map_err(|e| step_err(e.to_string()))?;
Ok(ExecutionResult::next())
}
}
// ── ApplyVaultStaticSecret ─────────────────────────────────────────────────
/// Create the VaultStaticSecret CRD that VSO will sync.
#[derive(Default)]
pub struct ApplyVaultStaticSecret;
#[async_trait::async_trait]
impl StepBody for ApplyVaultStaticSecret {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
ok(&format!("Creating VaultStaticSecret {TEST_NS}/{TEST_NAME}..."));
k::kube_apply(&format!(
r#"
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: {TEST_NAME}
namespace: {TEST_NS}
spec:
vaultAuthRef: {TEST_NAME}
mount: secret
type: kv-v2
path: vso-test
refreshAfter: 10s
destination:
name: {TEST_NAME}
create: true
overwrite: true
"#
))
.await
.map_err(|e| step_err(e.to_string()))?;
Ok(ExecutionResult::next())
}
}
// ── WaitForSync ────────────────────────────────────────────────────────────
/// Wait for VSO to sync the secret (up to 60s).
#[derive(Default)]
pub struct WaitForSync;
#[async_trait::async_trait]
impl StepBody for WaitForSync {
async fn run(
&mut self,
_ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
ok("Waiting for VSO to sync (up to 60s)...");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60);
let mut synced = false;
while tokio::time::Instant::now() < deadline {
let (code, mac) = kubectl_jsonpath(
TEST_NS,
"vaultstaticsecret",
TEST_NAME,
"{.status.secretMAC}",
)
.await;
if code == 0 && !mac.is_empty() && mac != "<none>" {
synced = true;
break;
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
if !synced {
let (_, msg) = kubectl_jsonpath(
TEST_NS,
"vaultstaticsecret",
TEST_NAME,
"{.status.conditions[0].message}",
)
.await;
return Err(step_err(format!(
"VSO did not sync within 60s. Last status: {}",
if msg.is_empty() { "unknown".to_string() } else { msg }
)));
}
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({ "synced": true }));
Ok(result)
}
}
// ── CheckSecretValue ───────────────────────────────────────────────────────
/// Verify the K8s Secret contains the expected sentinel value.
#[derive(Default)]
pub struct CheckSecretValue;
#[async_trait::async_trait]
impl StepBody for CheckSecretValue {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
let test_value = data.test_value.as_deref()
.ok_or_else(|| step_err("test_value not set"))?;
ok("Verifying K8s Secret contents...");
let secret = k::kube_get_secret(TEST_NS, TEST_NAME)
.await
.map_err(|e| step_err(e.to_string()))?
.ok_or_else(|| step_err(format!("K8s Secret {TEST_NS}/{TEST_NAME} not found")))?;
let secret_data = secret.data.as_ref()
.ok_or_else(|| step_err("Secret has no data"))?;
let raw = secret_data.get("test-key")
.ok_or_else(|| step_err("Missing key 'test-key' in secret"))?;
let actual = String::from_utf8(raw.0.clone())
.map_err(|e| step_err(format!("UTF-8 error: {e}")))?;
if actual != test_value {
return Err(step_err(format!(
"Value mismatch!\n expected: {:?}\n got: {:?}",
test_value, actual
)));
}
ok("Sentinel value matches -- VSO -> OpenBao integration is working.");
Ok(ExecutionResult::next())
}
}
// ── Cleanup ────────────────────────────────────────────────────────────────
/// Clean up all test resources (always runs).
#[derive(Default)]
pub struct Cleanup;
#[async_trait::async_trait]
impl StepBody for Cleanup {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
ok("Cleaning up test resources...");
let _ = secrets::delete_resource(TEST_NS, "vaultstaticsecret", TEST_NAME).await;
let _ = secrets::delete_resource(TEST_NS, "vaultauth", TEST_NAME).await;
// Delete the K8s Secret
if let Ok(client) = k::get_client().await {
let api: kube::Api<k8s_openapi::api::core::v1::Secret> =
kube::Api::namespaced(client.clone(), TEST_NS);
let _ = api.delete(TEST_NAME, &kube::api::DeleteParams::default()).await;
}
// Delete the vault KV entry
let data = load_data(ctx)?;
if let (Some(ob_pod), Some(root_token)) = (data.ob_pod.as_deref(), data.root_token.as_deref()) {
if let Ok(pf) = secrets::port_forward("data", ob_pod, 8200).await {
let bao = BaoClient::with_token(
&format!("http://127.0.0.1:{}", pf.local_port),
root_token,
);
let _ = bao.kv_delete("secret", "vso-test").await;
}
}
Ok(ExecutionResult::next())
}
}
// ── PrintResult ────────────────────────────────────────────────────────────
/// Print final verification result.
#[derive(Default)]
pub struct PrintResult;
#[async_trait::async_trait]
impl StepBody for PrintResult {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = load_data(ctx)?;
if data.synced {
ok("VSO E2E verification passed.");
} else {
warn("VSO verification did not complete successfully.");
}
Ok(ExecutionResult::next())
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
async fn kubectl_jsonpath(ns: &str, kind: &str, name: &str, jsonpath: &str) -> (i32, String) {
let ctx = format!("--context={}", k::context());
let jp = format!("-o=jsonpath={jsonpath}");
match tokio::process::Command::new("kubectl")
.args([&ctx, "-n", ns, "get", kind, name, &jp, "--ignore-not-found"])
.output()
.await
{
Ok(output) => {
let code = output.status.code().unwrap_or(1);
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
(code, stdout)
}
Err(_) => (1, String::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_openbao_pod_is_default() { let _ = FindOpenBaoPod::default(); }
#[test]
fn get_root_token_is_default() { let _ = GetRootToken::default(); }
#[test]
fn write_sentinel_is_default() { let _ = WriteSentinel::default(); }
#[test]
fn apply_vault_auth_is_default() { let _ = ApplyVaultAuth::default(); }
#[test]
fn apply_vault_static_secret_is_default() { let _ = ApplyVaultStaticSecret::default(); }
#[test]
fn wait_for_sync_is_default() { let _ = WaitForSync::default(); }
#[test]
fn check_secret_value_is_default() { let _ = CheckSecretValue::default(); }
#[test]
fn cleanup_is_default() { let _ = Cleanup::default(); }
#[test]
fn print_result_is_default() { let _ = PrintResult::default(); }
#[test]
fn test_constants() {
assert_eq!(TEST_NS, "ory");
assert_eq!(TEST_NAME, "vso-verify");
}
}