nip/src/nimpak/generation_filesystem.nim

476 lines
17 KiB
Nim

# SPDX-License-Identifier: LSL-1.0
# Copyright (c) 2026 Markus Maiwald
# Stewardship: Self Sovereign Society Foundation
#
# This file is part of the Nexus Sovereign Core.
# See legal/LICENSE_SOVEREIGN.md for license terms.
## nimpak/generation_filesystem.nim
## Generation-aware filesystem operations for NimPak
##
## This module implements the integration between generation management
## and filesystem operations, providing atomic system state changes.
import std/[os, strutils, times, json, tables, sequtils, osproc, algorithm]
type
GenerationFilesystemError* = object of CatchableError
path*: string
GenerationManager* = object
generationsRoot*: string ## /System/Generations - Generation metadata
programsRoot*: string ## /Programs - Package installation directory
indexRoot*: string ## /System/Index - Symlink directory
currentGeneration*: string ## Current active generation ID
dryRun*: bool ## Dry run mode for testing
GenerationInfo* = object
id*: string
timestamp*: times.DateTime
packages*: seq[string] ## Package names in this generation
previous*: string ## Previous generation ID (empty if first)
size*: int64
SymlinkOperation* = object
source*: string ## Source file in /Programs
target*: string ## Target symlink in /System/Index
operation*: string ## "create", "update", "remove"
# =============================================================================
# Generation Manager Creation and Configuration
# =============================================================================
proc newGenerationManager*(programsRoot: string = "/Programs",
indexRoot: string = "/System/Index",
generationsRoot: string = "/System/Generations",
dryRun: bool = false): GenerationManager =
## Create a new GenerationManager with specified paths
GenerationManager(
programsRoot: programsRoot,
indexRoot: indexRoot,
generationsRoot: generationsRoot,
currentGeneration: "", # Will be loaded from filesystem
dryRun: dryRun
)
proc loadCurrentGeneration*(gm: var GenerationManager): bool =
## Load the current generation ID from filesystem
try:
let currentGenFile = gm.generationsRoot / "current"
if fileExists(currentGenFile):
gm.currentGeneration = readFile(currentGenFile).strip()
return true
else:
# No current generation - this is a fresh system
gm.currentGeneration = ""
return true
except:
return false
# =============================================================================
# Symlink Operations (defined early for forward references)
# =============================================================================
proc applySymlinkOperationsImpl*(gm: GenerationManager, operations: seq[SymlinkOperation],
generationId: string): bool =
## Apply symlink operations atomically with backup capability
try:
let backupDir = gm.generationsRoot / generationId / "symlink_backup"
if not gm.dryRun:
createDir(backupDir)
# Phase 1: Backup existing symlinks
for op in operations:
if symlinkExists(op.target):
let currentTarget = expandSymlink(op.target)
if not gm.dryRun:
let backupFile = backupDir / extractFilename(op.target) & ".backup"
writeFile(backupFile, currentTarget)
elif fileExists(op.target):
# Handle regular files that need to be replaced
if not gm.dryRun:
let backupFile = backupDir / extractFilename(op.target) & ".file"
copyFile(op.target, backupFile)
# Phase 2: Apply new symlinks
for op in operations:
case op.operation:
of "create", "update":
let targetDir = parentDir(op.target)
if not gm.dryRun:
# Ensure target directory exists
if not dirExists(targetDir):
createDir(targetDir)
# Remove existing file/symlink
if fileExists(op.target) or symlinkExists(op.target):
removeFile(op.target)
# Create new symlink
createSymlink(op.source, op.target)
else:
echo "DRY RUN: Would create symlink ", op.source, " -> ", op.target
of "remove":
if not gm.dryRun:
if fileExists(op.target) or symlinkExists(op.target):
removeFile(op.target)
else:
echo "DRY RUN: Would remove symlink ", op.target
# Phase 3: Record generation symlink state
if not gm.dryRun:
let symlinkStateFile = gm.generationsRoot / generationId / "symlinks.json"
let symlinkState = %*{
"generation": generationId,
"timestamp": $now(),
"symlinks": operations.mapIt(%*{
"source": it.source,
"target": it.target,
"operation": it.operation
}),
"backup_location": backupDir
}
writeFile(symlinkStateFile, $symlinkState)
return true
except:
echo "ERROR: Failed to apply symlink operations"
return false
# =============================================================================
# Generation Creation and Management
# =============================================================================
proc createGeneration*(gm: GenerationManager, generationId: string,
packages: seq[string]): bool =
## Create a new generation with the specified packages
try:
let generationDir = gm.generationsRoot / generationId
if not gm.dryRun:
createDir(generationDir)
# Create generation metadata
let generationInfo = GenerationInfo(
id: generationId,
timestamp: now(),
packages: packages,
previous: gm.currentGeneration,
size: 0 # Will be calculated
)
# Save generation metadata as JSON
let generationJson = %*{
"id": generationInfo.id,
"timestamp": $generationInfo.timestamp,
"packages": generationInfo.packages,
"previous": generationInfo.previous,
"size": generationInfo.size
}
if not gm.dryRun:
let generationFile = generationDir / "generation.json"
writeFile(generationFile, $generationJson)
else:
echo "DRY RUN: Would create generation ", generationId
return true
except:
return false
proc switchToGeneration*(gm: var GenerationManager, targetGenerationId: string): bool =
## Switch the system to a specific generation atomically
try:
let generationDir = gm.generationsRoot / targetGenerationId
let generationFile = generationDir / "generation.json"
if not fileExists(generationFile):
echo "ERROR: Generation not found: ", targetGenerationId
return false
# Load generation metadata
let generationJson = parseJson(readFile(generationFile))
let packages = generationJson["packages"].getElems().mapIt(it.getStr())
# Create symlinks for all packages in this generation
var symlinkOps: seq[SymlinkOperation] = @[]
for packageName in packages:
# Find the package directory (assuming latest version for now)
let packageBaseDir = gm.programsRoot / packageName
if dirExists(packageBaseDir):
# Get the latest version directory
var latestVersion = ""
for kind, path in walkDir(packageBaseDir):
if kind == pcDir:
let version = extractFilename(path)
if latestVersion.len == 0 or version > latestVersion:
latestVersion = version
if latestVersion.len > 0:
let packageDir = packageBaseDir / latestVersion
# Scan for binaries to symlink
let binDir = packageDir / "bin"
if dirExists(binDir):
for kind, path in walkDir(binDir):
if kind == pcFile:
let fileName = extractFilename(path)
symlinkOps.add(SymlinkOperation(
source: path,
target: gm.indexRoot / "bin" / fileName,
operation: "create"
))
# Scan for libraries to symlink
let libDir = packageDir / "lib"
if dirExists(libDir):
for kind, path in walkDir(libDir):
if kind == pcFile and (path.endsWith(".so") or path.contains(".so.")):
let fileName = extractFilename(path)
symlinkOps.add(SymlinkOperation(
source: path,
target: gm.indexRoot / "lib" / fileName,
operation: "create"
))
# Apply symlink operations atomically
if applySymlinkOperationsImpl(gm, symlinkOps, targetGenerationId):
# Update current generation pointer
if not gm.dryRun:
let currentGenFile = gm.generationsRoot / "current"
writeFile(currentGenFile, targetGenerationId)
gm.currentGeneration = targetGenerationId
echo "Successfully switched to generation: ", targetGenerationId
return true
else:
echo "ERROR: Failed to apply symlink operations"
return false
except:
echo "ERROR: Failed to switch to generation: ", targetGenerationId
return false
proc rollbackToPreviousGeneration*(gm: var GenerationManager): bool =
## Rollback to the previous generation
try:
if gm.currentGeneration.len == 0:
echo "ERROR: No current generation to rollback from"
return false
# Load current generation metadata
let currentGenFile = gm.generationsRoot / gm.currentGeneration / "generation.json"
if not fileExists(currentGenFile):
echo "ERROR: Current generation metadata not found"
return false
let currentGenJson = parseJson(readFile(currentGenFile))
let previousGenId = currentGenJson["previous"].getStr()
if previousGenId.len == 0:
echo "ERROR: No previous generation available for rollback"
return false
# Switch to previous generation
if switchToGeneration(gm, previousGenId):
echo "Successfully rolled back to generation: ", previousGenId
return true
else:
echo "ERROR: Failed to rollback to previous generation"
return false
except:
echo "ERROR: Failed to rollback generation"
return false
# =============================================================================
# Generation Information and Utilities
# =============================================================================
proc listGenerations*(gm: GenerationManager): seq[GenerationInfo] =
## List all available generations
var generations: seq[GenerationInfo] = @[]
try:
if not dirExists(gm.generationsRoot):
return generations
for kind, path in walkDir(gm.generationsRoot):
if kind == pcDir:
let generationId = extractFilename(path)
if generationId != "current": # Skip the current symlink
let generationFile = path / "generation.json"
if fileExists(generationFile):
try:
let generationJson = parseJson(readFile(generationFile))
let genInfo = GenerationInfo(
id: generationJson["id"].getStr(),
timestamp: parse(generationJson["timestamp"].getStr(), "yyyy-MM-dd'T'HH:mm:ss'.'fff'Z'", utc()),
packages: generationJson["packages"].getElems().mapIt(it.getStr()),
previous: generationJson["previous"].getStr(),
size: generationJson["size"].getInt()
)
generations.add(genInfo)
except:
# Skip invalid generation files
continue
except:
# Return empty list on error
discard
# Sort by timestamp (newest first)
generations.sort do (a, b: GenerationInfo) -> int:
if a.timestamp > b.timestamp: -1
elif a.timestamp < b.timestamp: 1
else: 0
return generations
proc getGenerationInfo*(gm: GenerationManager, generationId: string): GenerationInfo =
## Get detailed information about a specific generation
try:
let generationFile = gm.generationsRoot / generationId / "generation.json"
if fileExists(generationFile):
let generationJson = parseJson(readFile(generationFile))
return GenerationInfo(
id: generationJson["id"].getStr(),
timestamp: parse(generationJson["timestamp"].getStr(), "yyyy-MM-dd'T'HH:mm:ss'.'fff'Z'", utc()),
packages: generationJson["packages"].getElems().mapIt(it.getStr()),
previous: generationJson["previous"].getStr(),
size: generationJson["size"].getInt()
)
except:
discard
# Return empty generation info on error
return GenerationInfo(
id: "",
timestamp: now(),
packages: @[],
previous: "",
size: 0
)
proc repairGeneration*(gm: GenerationManager, generationId: string): bool =
## Repair a corrupted generation by rebuilding symlinks
try:
let generationDir = gm.generationsRoot / generationId
let symlinkStateFile = generationDir / "symlinks.json"
if not fileExists(symlinkStateFile):
echo "ERROR: Generation symlink state not found for repair: ", generationId
return false
# Load expected symlink state
let symlinkStateJson = parseJson(readFile(symlinkStateFile))
var expectedOperations: seq[SymlinkOperation] = @[]
for linkJson in symlinkStateJson["symlinks"].getElems():
expectedOperations.add(SymlinkOperation(
source: linkJson["source"].getStr(),
target: linkJson["target"].getStr(),
operation: linkJson["operation"].getStr()
))
# Apply the operations to repair the generation
if applySymlinkOperationsImpl(gm, expectedOperations, generationId):
echo "Successfully repaired generation: ", generationId
return true
else:
echo "ERROR: Failed to repair generation: ", generationId
return false
except:
echo "ERROR: Failed to repair generation: ", generationId
return false
# =============================================================================
# Boot Integration Support
# =============================================================================
proc createBootEntry*(gm: GenerationManager, generationId: string, bootDir: string = "/boot"): bool =
## Create boot entry for generation selection
try:
let bootEntryDir = bootDir / "nexus" / "generations"
if not gm.dryRun:
createDir(bootEntryDir)
let bootEntryFile = bootEntryDir / generationId & ".conf"
let bootEntry = """
title NexusOS Generation """ & generationId & """
version """ & generationId & """
linux /nexus/kernel
initrd /nexus/initrd
options nexus.generation=""" & generationId & """ root=LABEL=nexus-root
"""
if not gm.dryRun:
writeFile(bootEntryFile, bootEntry)
echo "Created boot entry for generation: ", generationId
else:
echo "DRY RUN: Would create boot entry ", bootEntryFile
return true
except:
echo "ERROR: Failed to create boot entry for generation: ", generationId
return false
proc setDefaultBootGeneration*(gm: GenerationManager, generationId: string, bootDir: string = "/boot"): bool =
## Set the default boot generation
try:
let defaultBootFile = bootDir / "nexus" / "default_generation"
if not gm.dryRun:
writeFile(defaultBootFile, generationId)
echo "Set default boot generation to: ", generationId
else:
echo "DRY RUN: Would set default boot generation to ", generationId
return true
except:
echo "ERROR: Failed to set default boot generation: ", generationId
return false
# =============================================================================
# CLI Integration Functions
# =============================================================================
proc printGenerationStatus*(gm: GenerationManager) =
## Print current generation status
echo "=== Generation Status ==="
echo "Current Generation: ", if gm.currentGeneration.len > 0: gm.currentGeneration else: "None"
echo "Generations Root: ", gm.generationsRoot
echo "Programs Root: ", gm.programsRoot
echo "Index Root: ", gm.indexRoot
let generations = listGenerations(gm)
echo "Available Generations: ", generations.len
for gen in generations:
let marker = if gen.id == gm.currentGeneration: " (current)" else: ""
echo " - ", gen.id, " (", gen.packages.len, " packages)", marker
proc printGenerationDetails*(gm: GenerationManager, generationId: string) =
## Print detailed information about a generation
let genInfo = getGenerationInfo(gm, generationId)
if genInfo.id.len == 0:
echo "ERROR: Generation not found: ", generationId
return
echo "=== Generation Details ==="
echo "ID: ", genInfo.id
echo "Timestamp: ", genInfo.timestamp
echo "Previous: ", if genInfo.previous.len > 0: genInfo.previous else: "None"
echo "Size: ", genInfo.size, " bytes"
echo "Packages (", genInfo.packages.len, "):"
for pkg in genInfo.packages:
echo " - ", pkg