nip/src/nimpak/filesystem.nim

654 lines
23 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/filesystem.nim
## GoboLinux-style filesystem management with generation integration
##
## This module implements the filesystem operations for NimPak, including:
## - GoboLinux-style /Programs/App/Version directory structure
## - Atomic symlink management in /System/Index
## - Generation-aware filesystem operations
## - Boot integration for generation selection
import std/[os, strutils, times, json, tables, sequtils, algorithm, osproc]
import ./types_fixed
type
FilesystemError* = object of types_fixed.NimPakError
path*: string
EnhancedFilesystemManager* = object
programsRoot*: string ## /Programs - Package installation directory
indexRoot*: string ## /System/Index - Symlink directory
generationsRoot*: string ## /System/Generations - Generation metadata
currentGeneration*: string ## Current active generation ID
dryRun*: bool ## Dry run mode for testing
GenerationFilesystem* = object
generation*: Generation
symlinkMap*: Table[string, string] ## target -> source mapping
backupPath*: string ## Backup location for rollback
# =============================================================================
# FilesystemManager Creation and Configuration
# =============================================================================
proc newEnhancedFilesystemManager*(programsRoot: string = "/Programs",
indexRoot: string = "/System/Index",
generationsRoot: string = "/System/Generations",
dryRun: bool = false): EnhancedFilesystemManager =
## Create a new EnhancedFilesystemManager with specified paths
EnhancedFilesystemManager(
programsRoot: programsRoot,
indexRoot: indexRoot,
generationsRoot: generationsRoot,
currentGeneration: "", # Will be loaded from filesystem
dryRun: dryRun
)
proc loadCurrentGeneration*(fm: var EnhancedFilesystemManager): Result[void, FilesystemError] =
## Load the current generation ID from filesystem
try:
let currentGenFile = fm.generationsRoot / "current"
if fileExists(currentGenFile):
fm.currentGeneration = readFile(currentGenFile).strip()
else:
# No current generation - this is a fresh system
fm.currentGeneration = ""
return ok[void, FilesystemError]()
except IOError as e:
return err[void, FilesystemError](FilesystemError(
code: FileReadError,
msg: "Failed to load current generation: " & e.msg,
path: fm.generationsRoot / "current"
))
# =============================================================================
# Package Installation with Generation Integration
# =============================================================================
proc installPackage*(fm: EnhancedFilesystemManager, pkg: Fragment, generation: Generation): Result[InstallLocation, FilesystemError] =
## Install a package to the filesystem with generation tracking
try:
let programDir = fm.programsRoot / pkg.id.name / pkg.id.version
# Create program directory structure
if not fm.dryRun:
createDir(programDir)
createDir(programDir / "bin")
createDir(programDir / "lib")
createDir(programDir / "share")
createDir(programDir / "etc")
# Generate symlinks for this package
var indexLinks: seq[SymlinkPair] = @[]
# Scan for binaries to symlink
let binDir = programDir / "bin"
if dirExists(binDir):
for file in walkDir(binDir):
if file.kind == pcFile:
let fileName = extractFilename(file.path)
indexLinks.add(SymlinkPair(
source: file.path,
target: fm.indexRoot / "bin" / fileName
))
# Scan for libraries to symlink
let libDir = programDir / "lib"
if dirExists(libDir):
for file in walkDir(libDir):
if file.kind == pcFile and (file.path.endsWith(".so") or file.path.contains(".so.")):
let fileName = extractFilename(file.path)
indexLinks.add(SymlinkPair(
source: file.path,
target: fm.indexRoot / "lib" / fileName
))
# Scan for shared data to symlink
let shareDir = programDir / "share"
if dirExists(shareDir):
for subdir in walkDir(shareDir):
if subdir.kind == pcDir:
let dirName = extractFilename(subdir.path)
indexLinks.add(SymlinkPair(
source: subdir.path,
target: fm.indexRoot / "share" / dirName
))
let location = InstallLocation(
programDir: programDir,
indexLinks: indexLinks
)
return ok[InstallLocation, FilesystemError](location)
except OSError as e:
return err[InstallLocation, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to install package: " & e.msg,
path: fm.programsRoot / pkg.id.name / pkg.id.version
))
# =============================================================================
# Atomic Symlink Management
# =============================================================================
proc createSymlinks*(fm: EnhancedFilesystemManager, location: InstallLocation, generation: Generation): Result[void, FilesystemError] =
## Create symlinks for package installation with generation tracking
try:
for link in location.indexLinks:
let targetDir = parentDir(link.target)
if not fm.dryRun:
# Ensure target directory exists
if not dirExists(targetDir):
createDir(targetDir)
# Create the symlink (remove existing if present)
if symlinkExists(link.target) or fileExists(link.target):
removeFile(link.target)
createSymlink(link.source, link.target)
else:
echo "DRY RUN: Would create symlink " & link.source & " -> " & link.target
return ok[void, FilesystemError]()
except OSError as e:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to create symlinks: " & e.msg,
path: location.programDir
))
proc atomicSymlinkUpdate*(fm: EnhancedFilesystemManager, updates: seq[SymlinkPair], generation: Generation): Result[void, FilesystemError] =
## Atomically update symlinks with generation tracking and rollback capability
try:
# Create backup of current symlink state
let backupDir = fm.generationsRoot / generation.id / "symlink_backup"
if not fm.dryRun:
createDir(backupDir)
var backupData: seq[SymlinkPair] = @[]
# Phase 1: Backup existing symlinks
for update in updates:
if symlinkExists(update.target):
let currentTarget = expandSymlink(update.target)
backupData.add(SymlinkPair(
source: currentTarget,
target: update.target
))
if not fm.dryRun:
# Save backup information
let backupFile = backupDir / extractFilename(update.target) & ".backup"
writeFile(backupFile, currentTarget)
elif fileExists(update.target):
# Handle regular files that need to be replaced
let backupFile = backupDir / extractFilename(update.target) & ".file"
if not fm.dryRun:
copyFile(update.target, backupFile)
# Phase 2: Apply new symlinks atomically
for update in updates:
let targetDir = parentDir(update.target)
if not fm.dryRun:
# Ensure target directory exists
if not dirExists(targetDir):
createDir(targetDir)
# Remove existing file/symlink
if fileExists(update.target) or symlinkExists(update.target):
removeFile(update.target)
# Create new symlink
createSymlink(update.source, update.target)
else:
echo "DRY RUN: Would update symlink " & update.source & " -> " & update.target
# Phase 3: Record generation symlink state
if not fm.dryRun:
let symlinkStateFile = fm.generationsRoot / generation.id / "symlinks.json"
let symlinkState = %*{
"generation": generation.id,
"timestamp": $generation.timestamp,
"symlinks": updates.mapIt(%*{
"source": it.source,
"target": it.target
}),
"backup_location": backupDir
}
writeFile(symlinkStateFile, $symlinkState)
return ok[void, FilesystemError]()
except OSError as e:
# Attempt rollback on failure
discard rollbackSymlinks(fm, backupData)
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to update symlinks atomically: " & e.msg,
path: fm.indexRoot
))
proc rollbackSymlinks*(fm: EnhancedFilesystemManager, backupData: seq[SymlinkPair]): Result[void, FilesystemError] =
## Rollback symlinks to previous state
try:
for backup in backupData:
if not fm.dryRun:
# Remove current symlink
if fileExists(backup.target) or symlinkExists(backup.target):
removeFile(backup.target)
# Restore original symlink
createSymlink(backup.source, backup.target)
else:
echo "DRY RUN: Would rollback symlink " & backup.source & " -> " & backup.target
return ok[void, FilesystemError]()
except OSError as e:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to rollback symlinks: " & e.msg,
path: fm.indexRoot
))
# =============================================================================
# Generation Switching and Management
# =============================================================================
proc switchToGeneration*(fm: var EnhancedFilesystemManager, targetGeneration: Generation): Result[void, FilesystemError] =
## Switch the system to a specific generation atomically
try:
let generationDir = fm.generationsRoot / targetGeneration.id
let symlinkStateFile = generationDir / "symlinks.json"
if not fileExists(symlinkStateFile):
return err[void, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "Generation symlink state not found: " & targetGeneration.id,
path: symlinkStateFile
))
# Load generation symlink state
let symlinkStateJson = parseJson(readFile(symlinkStateFile))
var targetSymlinks: seq[SymlinkPair] = @[]
for linkJson in symlinkStateJson["symlinks"].getElems():
targetSymlinks.add(SymlinkPair(
source: linkJson["source"].getStr(),
target: linkJson["target"].getStr()
))
# Perform atomic symlink update to target generation
let updateResult = atomicSymlinkUpdate(fm, targetSymlinks, targetGeneration)
if updateResult.isErr:
return updateResult
# Update current generation pointer
if not fm.dryRun:
let currentGenFile = fm.generationsRoot / "current"
writeFile(currentGenFile, targetGeneration.id)
fm.currentGeneration = targetGeneration.id
return ok[void, FilesystemError]()
except JsonParsingError as e:
return err[void, FilesystemError](FilesystemError(
code: InvalidMetadata,
msg: "Failed to parse generation metadata: " & e.msg,
path: fm.generationsRoot / targetGeneration.id
))
except IOError as e:
return err[void, FilesystemError](FilesystemError(
code: FileReadError,
msg: "Failed to switch generation: " & e.msg,
path: fm.generationsRoot / targetGeneration.id
))
proc rollbackToPreviousGeneration*(fm: var EnhancedFilesystemManager): Result[Generation, FilesystemError] =
## Rollback to the previous generation
try:
if fm.currentGeneration.len == 0:
return err[Generation, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "No current generation to rollback from",
path: fm.generationsRoot
))
# Load current generation metadata
let currentGenFile = fm.generationsRoot / fm.currentGeneration / "generation.json"
if not fileExists(currentGenFile):
return err[Generation, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "Current generation metadata not found",
path: currentGenFile
))
let currentGenJson = parseJson(readFile(currentGenFile))
let previousGenId = currentGenJson.getOrDefault("previous")
if previousGenId.isNil or previousGenId.getStr().len == 0:
return err[Generation, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "No previous generation available for rollback",
path: currentGenFile
))
# Load previous generation
let previousGenFile = fm.generationsRoot / previousGenId.getStr() / "generation.json"
let previousGenJson = parseJson(readFile(previousGenFile))
let previousGeneration = Generation(
id: previousGenId.getStr(),
timestamp: previousGenJson["timestamp"].getStr().parseTime("yyyy-MM-dd'T'HH:mm:ss'.'fff'Z'", utc()),
packages: previousGenJson["packages"].getElems().mapIt(PackageId(
name: it["name"].getStr(),
version: it["version"].getStr(),
stream: parseEnum[PackageStream](it["stream"].getStr())
)),
previous: if previousGenJson.hasKey("previous") and not previousGenJson["previous"].isNil:
some(previousGenJson["previous"].getStr())
else:
none(string),
size: previousGenJson["size"].getInt()
)
# Switch to previous generation
let switchResult = switchToGeneration(fm, previousGeneration)
if switchResult.isErr:
return err[Generation, FilesystemError](switchResult.getError())
return ok[Generation, FilesystemError](previousGeneration)
except JsonParsingError as e:
return err[Generation, FilesystemError](FilesystemError(
code: InvalidMetadata,
msg: "Failed to parse generation metadata: " & e.msg,
path: fm.generationsRoot
))
except IOError as e:
return err[Generation, FilesystemError](FilesystemError(
code: FileReadError,
msg: "Failed to rollback generation: " & e.msg,
path: fm.generationsRoot
))
# =============================================================================
# Generation Export and Import
# =============================================================================
proc exportGeneration*(fm: EnhancedFilesystemManager, generation: Generation, exportPath: string): Result[void, FilesystemError] =
## Export a generation for system migration
try:
let generationDir = fm.generationsRoot / generation.id
if not dirExists(generationDir):
return err[void, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "Generation directory not found: " & generation.id,
path: generationDir
))
# Create export archive
let exportCmd = "tar -czf " & exportPath & " -C " & generationDir & " ."
let result = execProcess(exportCmd, options = {poUsePath})
if result.exitCode != 0:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to export generation: " & result.output,
path: exportPath
))
return ok[void, FilesystemError]()
except OSError as e:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to export generation: " & e.msg,
path: exportPath
))
proc importGeneration*(fm: EnhancedFilesystemManager, importPath: string, newGenerationId: string): Result[Generation, FilesystemError] =
## Import a generation from archive
try:
let generationDir = fm.generationsRoot / newGenerationId
if dirExists(generationDir):
return err[Generation, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Generation already exists: " & newGenerationId,
path: generationDir
))
# Create generation directory
if not fm.dryRun:
createDir(generationDir)
# Extract archive
let extractCmd = "tar -xzf " & importPath & " -C " & generationDir
let result = execProcess(extractCmd, options = {poUsePath})
if result.exitCode != 0:
return err[Generation, FilesystemError](FilesystemError(
code: FileReadError,
msg: "Failed to import generation: " & result.output,
path: importPath
))
# Load generation metadata
let generationFile = generationDir / "generation.json"
let generationJson = parseJson(readFile(generationFile))
let importedGeneration = Generation(
id: newGenerationId, # Use new ID
timestamp: now(), # Update timestamp
packages: generationJson["packages"].getElems().mapIt(PackageId(
name: it["name"].getStr(),
version: it["version"].getStr(),
stream: parseEnum[PackageStream](it["stream"].getStr())
)),
previous: some(fm.currentGeneration), # Link to current generation
size: generationJson["size"].getInt()
)
return ok[Generation, FilesystemError](importedGeneration)
except JsonParsingError as e:
return err[Generation, FilesystemError](FilesystemError(
code: InvalidMetadata,
msg: "Failed to parse imported generation: " & e.msg,
path: importPath
))
except IOError as e:
return err[Generation, FilesystemError](FilesystemError(
code: FileReadError,
msg: "Failed to import generation: " & e.msg,
path: importPath
))
# =============================================================================
# Generation Repair and Recovery
# =============================================================================
proc repairGeneration*(fm: EnhancedFilesystemManager, generation: Generation): Result[void, FilesystemError] =
## Repair a corrupted generation by rebuilding symlinks
try:
let generationDir = fm.generationsRoot / generation.id
let symlinkStateFile = generationDir / "symlinks.json"
if not fileExists(symlinkStateFile):
return err[void, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "Generation symlink state not found for repair: " & generation.id,
path: symlinkStateFile
))
# Load expected symlink state
let symlinkStateJson = parseJson(readFile(symlinkStateFile))
var expectedSymlinks: seq[SymlinkPair] = @[]
for linkJson in symlinkStateJson["symlinks"].getElems():
expectedSymlinks.add(SymlinkPair(
source: linkJson["source"].getStr(),
target: linkJson["target"].getStr()
))
# Verify and repair each symlink
var repairedCount = 0
for expected in expectedSymlinks:
let needsRepair = if symlinkExists(expected.target):
expandSymlink(expected.target) != expected.source
else:
true # Missing symlink needs repair
if needsRepair:
if not fm.dryRun:
# Remove incorrect symlink/file
if fileExists(expected.target) or symlinkExists(expected.target):
removeFile(expected.target)
# Ensure target directory exists
let targetDir = parentDir(expected.target)
if not dirExists(targetDir):
createDir(targetDir)
# Create correct symlink
createSymlink(expected.source, expected.target)
else:
echo "DRY RUN: Would repair symlink " & expected.source & " -> " & expected.target
repairedCount += 1
if repairedCount > 0:
echo "Repaired " & $repairedCount & " symlinks in generation " & generation.id
else:
echo "Generation " & generation.id & " is healthy - no repairs needed"
return ok[void, FilesystemError]()
except JsonParsingError as e:
return err[void, FilesystemError](FilesystemError(
code: InvalidMetadata,
msg: "Failed to parse generation metadata for repair: " & e.msg,
path: generationDir
))
except OSError as e:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to repair generation: " & e.msg,
path: generationDir
))
# =============================================================================
# Boot Integration Support
# =============================================================================
proc createBootEntry*(fm: EnhancedFilesystemManager, generation: Generation, bootDir: string = "/boot"): Result[void, FilesystemError] =
## Create boot entry for generation selection
try:
let bootEntryDir = bootDir / "nexus" / "generations"
if not fm.dryRun:
createDir(bootEntryDir)
let bootEntryFile = bootEntryDir / generation.id & ".conf"
let bootEntry = """
title NexusOS Generation """ & generation.id & """
version """ & generation.id & """
linux /nexus/kernel
initrd /nexus/initrd
options nexus.generation=""" & generation.id & """ root=LABEL=nexus-root
"""
if not fm.dryRun:
writeFile(bootEntryFile, bootEntry)
else:
echo "DRY RUN: Would create boot entry " & bootEntryFile
return ok[void, FilesystemError]()
except IOError as e:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to create boot entry: " & e.msg,
path: bootDir
))
proc setDefaultBootGeneration*(fm: EnhancedFilesystemManager, generation: Generation, bootDir: string = "/boot"): Result[void, FilesystemError] =
## Set the default boot generation
try:
let defaultBootFile = bootDir / "nexus" / "default_generation"
if not fm.dryRun:
writeFile(defaultBootFile, generation.id)
else:
echo "DRY RUN: Would set default boot generation to " & generation.id
return ok[void, FilesystemError]()
except IOError as e:
return err[void, FilesystemError](FilesystemError(
code: FileWriteError,
msg: "Failed to set default boot generation: " & e.msg,
path: bootDir
))
# =============================================================================
# Utility Functions
# =============================================================================
proc getGenerationFilesystemInfo*(fm: EnhancedFilesystemManager, generation: Generation): Result[GenerationFilesystem, FilesystemError] =
## Get detailed filesystem information for a generation
try:
let generationDir = fm.generationsRoot / generation.id
let symlinkStateFile = generationDir / "symlinks.json"
if not fileExists(symlinkStateFile):
return err[GenerationFilesystem, FilesystemError](FilesystemError(
code: PackageNotFound,
msg: "Generation filesystem state not found: " & generation.id,
path: symlinkStateFile
))
let symlinkStateJson = parseJson(readFile(symlinkStateFile))
var symlinkMap: Table[string, string] = initTable[string, string]()
for linkJson in symlinkStateJson["symlinks"].getElems():
let target = linkJson["target"].getStr()
let source = linkJson["source"].getStr()
symlinkMap[target] = source
let backupPath = symlinkStateJson.getOrDefault("backup_location")
let backupLocation = if backupPath.isNil: "" else: backupPath.getStr()
let genFs = GenerationFilesystem(
generation: generation,
symlinkMap: symlinkMap,
backupPath: backupLocation
)
return ok[GenerationFilesystem, FilesystemError](genFs)
except JsonParsingError as e:
return err[GenerationFilesystem, FilesystemError](FilesystemError(
code: InvalidMetadata,
msg: "Failed to parse generation filesystem info: " & e.msg,
path: generationDir
))
except IOError as e:
return err[GenerationFilesystem, FilesystemError](FilesystemError(
code: FileReadError,
msg: "Failed to load generation filesystem info: " & e.msg,
path: generationDir
))