Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@ runs:
- name: Resolve GuestKit version
id: resolve-version
shell: bash
env:
GK_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
VERSION="${{ inputs.version }}"
VERSION="${GK_VERSION}"
if [[ "${VERSION}" == "latest" ]]; then
# Capture the full body before parsing — piping straight into
# `grep -m1` lets grep close the pipe after its first match,
Expand Down Expand Up @@ -161,6 +163,10 @@ runs:
GK_ISSUER: ${{ inputs.issuer }}
GK_REQUIRE_SIG: ${{ inputs.require-signature }}
GK_TRUST_KEYS: ${{ inputs.trust-keys }}
GK_HANDOFF: ${{ inputs.handoff }}
GK_RESCUE: ${{ inputs.rescue }}
GK_RESCUE_USER: ${{ inputs.rescue-user }}
GK_SBOM: ${{ inputs.sbom }}
run: |
set -uo pipefail
OUTDIR="${RUNNER_TEMP}/guestkit-passport-gate"
Expand Down Expand Up @@ -201,19 +207,19 @@ runs:
exit 1
fi

if [[ "${{ inputs.handoff }}" == "true" ]]; then
if [[ "${GK_HANDOFF}" == "true" ]]; then
guestkit passport handoff "${OUTDIR}/passport.json" -o "${OUTDIR}/handoff.yaml" --fail-below "${GK_FLOOR}" || true
fi

if [[ -n "${{ inputs.rescue }}" ]]; then
RESCUE_ARGS=(rescue "${GK_DISK}" -o "${{ inputs.rescue }}" --export-plan "${OUTDIR}/rescue.yaml")
if [[ -n "${{ inputs.rescue-user }}" ]]; then
RESCUE_ARGS+=(-u "${{ inputs.rescue-user }}")
if [[ -n "${GK_RESCUE}" ]]; then
RESCUE_ARGS=(rescue "${GK_DISK}" -o "${GK_RESCUE}" --export-plan "${OUTDIR}/rescue.yaml")
if [[ -n "${GK_RESCUE_USER}" ]]; then
RESCUE_ARGS+=(-u "${GK_RESCUE_USER}")
fi
guestkit "${RESCUE_ARGS[@]}" || echo "::warning::rescue dry-run export failed"
fi

if [[ "${{ inputs.sbom }}" == "true" ]]; then
if [[ "${GK_SBOM}" == "true" ]]; then
guestkit inventory "${GK_DISK}" --format spdx -o "${OUTDIR}/sbom.spdx.json" || echo "::warning::sbom emit failed"
fi

Expand Down
2 changes: 1 addition & 1 deletion build_python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ else
if [ -n "$VIRTUAL_ENV" ]; then
echo
echo "Installing wheel..."
WHEEL=$(ls -t target/wheels/guestkit-*.whl | head -1)
WHEEL=$(ls -t target/wheels/zyvor_guestkit-*.whl | head -1)
pip install --force-reinstall "$WHEEL"
fi
fi
Expand Down
2 changes: 1 addition & 1 deletion src/cli/plan/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ impl PlanApplicator {
}

// Topological sort of operations
let sorted_ops = super::topo_sort::topological_sort(plan);
let sorted_ops = super::topo_sort::topological_sort(plan)?;

let mut applied = 0usize;
let mut failed = 0usize;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/plan/executor_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ impl LivePlanExecutor {
pub fn apply(&self, plan: &FixPlan) -> Result<ApplyResult> {
let plan_id = plan_id_for(plan);
let rollback_dir = self.rollback_root().join(&plan_id);
let sorted = topological_sort(plan);
let sorted = topological_sort(plan)?;

let mut outcomes: Vec<OperationOutcome> = Vec::with_capacity(sorted.len());

Expand Down
105 changes: 98 additions & 7 deletions src/cli/plan/topo_sort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,50 @@
//! Topological sort for fix plan operations.

use super::types::{FixPlan, Operation};
use anyhow::{bail, Result};
use std::collections::{HashMap, VecDeque};

/// Sort operations respecting `depends_on` edges.
pub fn topological_sort(plan: &FixPlan) -> Vec<&Operation> {
///
/// Independent operations keep plan order. Returns an error for duplicate ids,
/// unknown dependencies and dependency cycles instead of silently dropping
/// operations.
pub fn topological_sort(plan: &FixPlan) -> Result<Vec<&Operation>> {
if plan.operations.is_empty() {
return Vec::new();
return Ok(Vec::new());
}

let mut in_degree: HashMap<&str, usize> = HashMap::new();
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
let mut op_map: HashMap<&str, &Operation> = HashMap::new();

for op in &plan.operations {
if op_map.insert(op.id.as_str(), op).is_some() {
bail!("duplicate operation id in plan: {}", op.id);
}
in_degree.entry(op.id.as_str()).or_insert(0);
adj.entry(op.id.as_str()).or_default();
op_map.insert(op.id.as_str(), op);
}

for op in &plan.operations {
for dep_id in &op.depends_on {
if !op_map.contains_key(dep_id.as_str()) {
bail!(
"operation {} depends on unknown operation {}",
op.id,
dep_id
);
}
adj.entry(dep_id.as_str()).or_default().push(op.id.as_str());
*in_degree.entry(op.id.as_str()).or_insert(0) += 1;
}
}

let mut queue: VecDeque<&str> = in_degree
let mut queue: VecDeque<&str> = plan
.operations
.iter()
.filter(|(_, &deg)| deg == 0)
.map(|(&node, _)| node)
.map(|op| op.id.as_str())
.filter(|id| in_degree.get(id) == Some(&0))
.collect();

let mut sorted = Vec::new();
Expand All @@ -53,5 +68,81 @@ pub fn topological_sort(plan: &FixPlan) -> Vec<&Operation> {
}
}

sorted
if sorted.len() != plan.operations.len() {
bail!("dependency cycle detected among plan operations");
}

Ok(sorted)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::cli::plan::types::*;

fn op(id: &str, deps: &[&str]) -> Operation {
Operation {
id: id.to_string(),
op_type: OperationType::FileEdit(FileEdit {
file: "/etc/x".into(),
backup: false,
changes: vec![],
}),
priority: Priority::Low,
description: String::new(),
risk: Priority::Low,
reversible: true,
depends_on: deps.iter().map(|d| d.to_string()).collect(),
validation: None,
undo: None,
}
}

fn plan(ops: Vec<Operation>) -> FixPlan {
FixPlan {
version: "1".into(),
vm: "d".into(),
generated: chrono::Utc::now(),
profile: "p".into(),
overall_risk: "low".into(),
estimated_duration: "1s".into(),
metadata: PlanMetadata {
author: "t".into(),
review_required: false,
reversible: true,
description: None,
tags: vec![],
},
operations: ops,
post_apply: vec![],
}
}

#[test]
fn keeps_plan_order_and_respects_deps() {
let p = plan(vec![
op("a", &["c"]),
op("b", &[]),
op("c", &[]),
op("d", &[]),
]);
let ids: Vec<_> = topological_sort(&p)
.unwrap()
.iter()
.map(|o| o.id.as_str())
.collect();
assert_eq!(ids, ["b", "c", "d", "a"]);
}

#[test]
fn cycle_is_an_error() {
let p = plan(vec![op("a", &["b"]), op("b", &["a"]), op("c", &[])]);
assert!(topological_sort(&p).is_err());
}

#[test]
fn unknown_dependency_is_an_error() {
let p = plan(vec![op("a", &["missing"])]);
assert!(topological_sort(&p).is_err());
}
}
Loading