# 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