476 lines
17 KiB
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 |