mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-14 04:59:28 +08:00
* feat(policy): add policy recommendation plumbing — denial aggregation, transport, approval pipeline, and mechanistic recommendations Implement the infrastructure layer for automated policy recommendations (#204): - Proto: 9 new RPCs and messages for draft policy lifecycle (submit, get, approve, reject, approve-all, edit, undo, clear, history) - Persistence: SQLite/Postgres migrations and store methods for draft_policy_chunks and denial_summaries tables - Server: Full gRPC handler implementations with mechanistic mapper that auto-generates NetworkPolicyRule proposals from denial summaries - Sandbox: DenialAggregator with MPSC channel, deduplication, periodic flush to gateway via SubmitPolicyAnalysis - CLI: 'openshell draft' subcommand with get/approve/reject/approve-all/undo/clear/history operations - TUI: Draft recommendations panel accessible from sandbox policy view - Docs: Architecture documentation in architecture/policy-advisor.md * feat(policy): add L7-aware mechanistic mapper and policy advisor CTF example Add L7 rule generation to mechanistic mapper (build_l7_rules, generalise_path, looks_like_id) with 3 new unit tests. Add examples/policy-advisor/ with a 7-gate CTF script, restrictive sandbox policy, and walkthrough README. * fix(policy): use sandbox name for denial flush and add TUI draft badges Fix denial aggregator passing sandbox UUID instead of name to SubmitPolicyAnalysis, which caused 'sandbox not found' errors on flush. Add notification badges to the TUI sandbox list and detail header showing pending draft recommendation counts. * fix(policy): deduplicate draft chunks and tolerate overlapping OPA rules Skip draft chunk creation when a pending/approved chunk already covers the same host:port endpoint, preventing duplicate rules across denial aggregator flush cycles. Rewrite three OPA complete rules (network_policy_for_request, matched_network_policy, matched_endpoint_config) to tolerate multiple matching policies without triggering a "complete rule conflict" error. network_policy_for_request becomes a boolean, matched_network_policy uses a set comprehension with min(), and matched_endpoint_config uses an array comprehension with index-0 selection. * feat(tui): interactive draft actions, highlight bar, and detail popup Rework the draft recommendations panel to match the logs UX: - Highlight bar (green accent + background) instead of arrow marker - Viewport-aware j/k scrolling with g/G for top/bottom - Enter opens a full-screen detail popup showing endpoints, binaries, rationale, security notes, and action hints Add approve/reject/approve-all draft actions: - [a] approve selected chunk, [x] reject, [A] approve all pending - Actions work from both the list view and the detail popup - gRPC calls run async; result updates status bar and refreshes data - Nav bar shows all available keybindings Fix draft count refresh: sandbox_draft_counts now refreshes on every tick (not just Dashboard), so the detail header badge updates in real time. Improve badge labels: show 'N pending' instead of a bare number in both the dashboard sandbox list and sandbox detail header. * refactor(policy): DB-level draft chunk dedup with hit counter and timestamps Replace the in-memory HashSet dedup in SubmitPolicyAnalysis with a database-level upsert. New denormalized columns on draft_policy_chunks: - host, port: extracted from proposed_rule at insert time - hit_count: incremented on conflict (same sandbox + host + port) - first_seen_ms, last_seen_ms: track when the endpoint was first and most recently proposed A partial unique index (WHERE status IN ('pending','approved')) ensures only one active chunk per endpoint per sandbox; rejected/superseded chunks don't block new proposals. Surface hit_count and first/last_seen in: - CLI: 'openshell draft get' shows 'Hits: N (first ..., last ...)' - TUI: detail popup shows hits row; list view shows 'Nx' suffix * fix(policy): optimistic retry on policy version conflicts + structured logging merge_chunk_into_policy and remove_chunk_from_policy now retry up to 5 times on UNIQUE constraint violations (version conflicts from concurrent approvals). Each attempt re-reads the latest policy, re-merges the rule, and increments the version. This eliminates the race condition where rapid successive approvals would fail with a DB error. Add structured tracing to all draft action handlers: - ApproveDraftChunk: logs rule_name, host, port, hit_count before merge and version + policy_hash after success - RejectDraftChunk: logs rule_name, host, port, reason - ApproveAllDraftChunks: logs pending_count at start, per-chunk merge progress, and final summary with chunks_approved/skipped - UndoDraftChunk: logs before/after with rule_name and version - Retry attempts log as warnings with attempt number and conflicting version * wip: forward proxy fix, mapper allowed_ips, TUI polish, CTF rewrite * fix(tui): use correct --gateway flag for ssh-proxy ProxyCommand * chore: add Docker cleanup script for stale images, volumes, and build cache * feat(tui): approve-all confirmation modal and CTF cleanup Add [A] confirmation popup that snapshots pending chunks, shows a scrollable list, and approves each chunk individually on confirm. This prevents approving chunks that arrived after the modal opened. Remove transient issue #205 reference from CTF victory banner. * fix(tui): correct import ordering for rustfmt * wip: stateful toggle model, rename to network rules Draft chunks now follow a toggle state machine: pending -> approved | rejected (initial decision) approved <-> rejected (toggle) One row per (sandbox_id, host, port) via expanded unique index. Rejecting an approved rule removes it from the active policy. Re-approving a rejected rule merges it back. Rename CLI from 'draft' to 'rule', TUI from 'Draft Recommendations' to 'Network Rules'. State-aware keybindings: approved shows [x] Revoke, rejected shows [a] Approve. Fix sandbox detail hiding delete confirmation behind pending message. * refactor(policy): move mapper sandbox-side, slim schema, per-binary granularity Move mechanistic mapper from gateway to sandbox so all analysis runs sandbox-side (N sandboxes = N independent pipelines). Gateway is now a thin validate + persist + approval layer. Architectural changes: - Move mechanistic_mapper.rs from navigator-server to navigator-sandbox - Sandbox flush flow: aggregator drains -> mapper runs -> proposals sent - Gateway SubmitPolicyAnalysis: validate + persist only, no mapper - Drop denial_summaries table (write-only, zero readers) - Consolidate migrations 003+004+005 into single 003 Schema slimming: - Drop 5 unused columns from draft_policy_chunks (stage, denial_refs, supersedes_chunk_id, analysis_mode, decided_by) - Add per-binary granularity: binary column, widen unique index to (sandbox_id, host, port, binary) - Mapper groups by (host, port, binary), one proposal per triple - Merge appends binary to existing rule; revoke removes just that binary CTF & UX: - 7-gate CTF: add Gate 3 (curl -> ifconfig.me:80) for per-binary demo - TUI shows binary short name in list, full path in detail popup - CLI output shows binary field - Idempotent rule names, hit_count accumulates real denial counts - Rationale text no longer bakes in stale denial count
288 lines
8.9 KiB
Bash
Executable File
288 lines
8.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# Clean up stale Docker images, volumes, and build cache that are not in use
|
|
# by the currently deployed OpenShell cluster.
|
|
#
|
|
# Preserves:
|
|
# - Current openshell/* :dev images (and 127.0.0.1:5000 registry mirrors)
|
|
# - Images actively used by running containers
|
|
# - Common base/infrastructure images (registry, k3s, alpine, rust, python, etc.)
|
|
# - Volumes attached to running containers
|
|
#
|
|
# Usage:
|
|
# ./scripts/docker-cleanup.sh [options]
|
|
#
|
|
# Options:
|
|
# --dry-run Show what would be removed without deleting anything
|
|
# --force Skip the confirmation prompt
|
|
# --skip-cache Skip build cache pruning (cache speeds up subsequent builds)
|
|
|
|
set -euo pipefail
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Options
|
|
# ---------------------------------------------------------------------------
|
|
DRY_RUN=false
|
|
SKIP_CACHE=false
|
|
FORCE=false
|
|
|
|
usage() {
|
|
sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# \?//'
|
|
exit 0
|
|
}
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--dry-run) DRY_RUN=true ;;
|
|
--skip-cache) SKIP_CACHE=true ;;
|
|
--force) FORCE=true ;;
|
|
-h|--help) usage ;;
|
|
*) echo "Unknown option: $arg"; usage ;;
|
|
esac
|
|
done
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
RED='\033[0;31m'
|
|
RESET='\033[0m'
|
|
|
|
info() { echo -e "${BOLD}==> $*${RESET}"; }
|
|
note() { echo -e " ${DIM}$*${RESET}"; }
|
|
ok() { echo -e " ${GREEN}$*${RESET}"; }
|
|
warn() { echo -e " ${YELLOW}$*${RESET}"; }
|
|
err() { echo -e " ${RED}$*${RESET}"; }
|
|
dry() { echo -e " ${YELLOW}[dry-run]${RESET} $*"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preflight
|
|
# ---------------------------------------------------------------------------
|
|
if ! command -v docker &>/dev/null; then
|
|
echo "Error: docker is not installed or not in PATH" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! docker info &>/dev/null; then
|
|
echo "Error: Docker daemon is not running" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image repository prefixes that belong to the CURRENT cluster deployment.
|
|
# Any tagged image whose repository starts with one of these is kept.
|
|
# ---------------------------------------------------------------------------
|
|
CURRENT_IMAGE_PREFIXES=(
|
|
"openshell/"
|
|
"127.0.0.1:5000/openshell/"
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Infrastructure / base images to always keep (matched by repository prefix).
|
|
# These are pulled from remote registries and are expensive to re-download.
|
|
# ---------------------------------------------------------------------------
|
|
KEEP_IMAGE_PREFIXES=(
|
|
"registry"
|
|
"rancher/k3s"
|
|
"ghcr.io/k3d-io/"
|
|
"kindest/"
|
|
"alpine"
|
|
"python"
|
|
"rust"
|
|
"ghcr.io/astral-sh/uv"
|
|
"redis"
|
|
"pgvector/"
|
|
"minio/"
|
|
)
|
|
|
|
# Returns 0 if the image repository should be kept, 1 otherwise.
|
|
should_keep_image() {
|
|
local repo="$1"
|
|
|
|
# Keep current cluster images
|
|
for prefix in "${CURRENT_IMAGE_PREFIXES[@]}"; do
|
|
if [[ "$repo" == "$prefix"* ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
# Keep infrastructure / base images
|
|
for prefix in "${KEEP_IMAGE_PREFIXES[@]}"; do
|
|
if [[ "$repo" == "$prefix"* ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshot disk usage before cleanup
|
|
# ---------------------------------------------------------------------------
|
|
info "Current Docker disk usage"
|
|
docker system df
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Confirmation
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "Running in dry-run mode -- nothing will be deleted"
|
|
echo
|
|
elif [[ "$FORCE" != true ]]; then
|
|
echo -e "${BOLD}This will remove stale images, unused volumes, and build cache.${RESET}"
|
|
echo "The currently deployed cluster images and running containers are preserved."
|
|
echo
|
|
read -r -p "Continue? [y/N] " confirm
|
|
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
|
echo "Aborted."
|
|
exit 0
|
|
fi
|
|
echo
|
|
fi
|
|
|
|
# Track totals for the summary
|
|
TOTAL_IMAGES_REMOVED=0
|
|
TOTAL_VOLUMES_REMOVED=0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 1: Remove dangling (untagged) images
|
|
# ---------------------------------------------------------------------------
|
|
info "Removing dangling images..."
|
|
|
|
dangling_count=$(docker images --filter "dangling=true" -q | wc -l | tr -d ' ')
|
|
if [[ "$dangling_count" -gt 0 ]]; then
|
|
note "Found $dangling_count dangling image(s)"
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
dry "Would remove $dangling_count dangling image(s)"
|
|
else
|
|
docker image prune -f | tail -1
|
|
TOTAL_IMAGES_REMOVED=$((TOTAL_IMAGES_REMOVED + dangling_count))
|
|
fi
|
|
else
|
|
ok "No dangling images found"
|
|
fi
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 2: Remove stale tagged images
|
|
# ---------------------------------------------------------------------------
|
|
info "Removing stale tagged images..."
|
|
|
|
# Collect image IDs that are directly used by running containers so we never
|
|
# touch them regardless of tag matching.
|
|
running_image_ids=$(docker ps -q 2>/dev/null | xargs -r docker inspect --format '{{.Image}}' 2>/dev/null | sort -u)
|
|
|
|
stale_images=()
|
|
while IFS=$'\t' read -r repo tag id; do
|
|
# Skip dangling images (already handled above)
|
|
[[ "$repo" == "<none>" ]] && continue
|
|
|
|
# Never remove images actively used by a container
|
|
if echo "$running_image_ids" | grep -q "$id" 2>/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
if ! should_keep_image "$repo"; then
|
|
stale_images+=("${repo}:${tag}")
|
|
fi
|
|
done < <(docker images --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}')
|
|
|
|
if [[ ${#stale_images[@]} -gt 0 ]]; then
|
|
for img in "${stale_images[@]}"; do
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
dry "Would remove $img"
|
|
else
|
|
note "Removing $img"
|
|
docker rmi "$img" >/dev/null 2>&1 || warn "Could not remove $img (may share layers)"
|
|
TOTAL_IMAGES_REMOVED=$((TOTAL_IMAGES_REMOVED + 1))
|
|
fi
|
|
done
|
|
else
|
|
ok "No stale tagged images found"
|
|
fi
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 3: Remove unused volumes
|
|
# ---------------------------------------------------------------------------
|
|
info "Removing unused volumes..."
|
|
|
|
# Identify volumes in use by running containers
|
|
in_use_volumes=$(docker ps -q 2>/dev/null \
|
|
| xargs -r docker inspect --format '{{range .Mounts}}{{.Name}} {{end}}' 2>/dev/null \
|
|
| tr ' ' '\n' | sort -u | grep -v '^$')
|
|
|
|
unused_volumes=()
|
|
while read -r vol; do
|
|
[[ -z "$vol" ]] && continue
|
|
if ! echo "$in_use_volumes" | grep -qx "$vol" 2>/dev/null; then
|
|
unused_volumes+=("$vol")
|
|
fi
|
|
done < <(docker volume ls -q)
|
|
|
|
if [[ ${#unused_volumes[@]} -gt 0 ]]; then
|
|
note "Found ${#unused_volumes[@]} unused volume(s)"
|
|
for vol in "${unused_volumes[@]}"; do
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
dry "Would remove volume $vol"
|
|
else
|
|
docker volume rm "$vol" >/dev/null 2>&1 || warn "Could not remove volume $vol"
|
|
TOTAL_VOLUMES_REMOVED=$((TOTAL_VOLUMES_REMOVED + 1))
|
|
fi
|
|
done
|
|
else
|
|
ok "No unused volumes found"
|
|
fi
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 4: Prune build cache
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$SKIP_CACHE" == true ]]; then
|
|
info "Skipping build cache prune (--skip-cache)"
|
|
else
|
|
info "Pruning build cache..."
|
|
cache_size=$(docker system df --format '{{.Size}}' 2>/dev/null | tail -1)
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
dry "Would prune build cache (current size: ${cache_size:-unknown})"
|
|
else
|
|
docker builder prune -af 2>&1 | tail -1
|
|
fi
|
|
fi
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 5: Clean up any newly-dangling images left after tagged image removal
|
|
# ---------------------------------------------------------------------------
|
|
remaining_dangling=$(docker images --filter "dangling=true" -q | wc -l | tr -d ' ')
|
|
if [[ "$remaining_dangling" -gt 0 ]]; then
|
|
info "Cleaning up $remaining_dangling residual dangling image(s)..."
|
|
if [[ "$DRY_RUN" != true ]]; then
|
|
docker image prune -f >/dev/null 2>&1
|
|
TOTAL_IMAGES_REMOVED=$((TOTAL_IMAGES_REMOVED + remaining_dangling))
|
|
fi
|
|
echo
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary
|
|
# ---------------------------------------------------------------------------
|
|
info "Cleanup complete"
|
|
echo
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "Dry run -- no changes were made. Re-run without --dry-run to apply."
|
|
else
|
|
docker system df
|
|
fi
|
|
echo
|
|
|
|
info "Remaining images:"
|
|
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
|