nip/src/nimpak/repo/publish.nim

433 lines
14 KiB
Nim

## Publish & Push Pipeline for NexusForge
##
## This module provides:
## - Artifact building from fragments/sources
## - Package signing with Ed25519
## - Repository upload (native + foreign)
##
## Leverages existing infrastructure:
## - `packages.nim` for NPK creation
## - `signature.nim` for Ed25519 signing
## - `cas.nim` for content-addressable storage
## - `remote/manager.nim` for repository access
import std/[os, strformat, strutils, json, options, times, sequtils, httpclient, asyncdispatch]
import ../cas
import ../types_fixed
import ../formats
import ../signature
import ../packages
import ../security/signature_verifier
import ../security/provenance_tracker
import ../remote/manager
import ../types/grafting_types
type
PublishConfig* = object
## Configuration for publishing packages
keyId*: string # Signing key ID
repoId*: string # Target repository ID
dryRun*: bool # Don't actually upload
signPackage*: bool # Sign with Ed25519
includeProvenance*: bool # Include provenance chain
compressionLevel*: int # zstd compression level (1-19)
PublishResult* = object
success*: bool
packageName*: string
version*: string
casHash*: string # CAS hash of published package
signature*: string # Ed25519 signature
repoUrl*: string # URL in repository
uploadedBytes*: int64
errors*: seq[string]
ArtifactSourceKind* = enum
FromDirectory,
FromCas,
FromGraft
ArtifactSource* = object
## Source for building an artifact
case kind*: ArtifactSourceKind
of FromDirectory:
sourceDir*: string
of FromCas:
files*: seq[types_fixed.PackageFile]
of FromGraft:
graftResult*: grafting_types.GraftResult
ArtifactBuilder* = ref object
cas*: CasManager
sigManager*: SignatureManager
config*: PublishConfig
outputDir*: string
# =============================================================================
# Artifact Building
# =============================================================================
proc newArtifactBuilder*(casRoot: string, keysRoot: string,
outputDir: string = ""): ArtifactBuilder =
## Create a new artifact builder
let actualOutput = if outputDir.len > 0: outputDir
else: getTempDir() / "nip-publish"
createDir(actualOutput)
ArtifactBuilder(
cas: initCasManager(casRoot),
sigManager: initSignatureManager(keysRoot),
config: PublishConfig(
signPackage: true,
includeProvenance: true,
compressionLevel: 3, # Fast compression
dryRun: false
),
outputDir: actualOutput
)
proc buildFromDirectory*(builder: ArtifactBuilder,
sourceDir: string,
name: string,
version: string): types_fixed.Result[NpkPackage, string] =
## Build NPK package from a directory of files
try:
# Collect all files
var files: seq[PackageFile] = @[]
var totalSize: int64 = 0
for file in walkDirRec(sourceDir, relative = true):
let fullPath = sourceDir / file
if fileExists(fullPath):
let data = readFile(fullPath)
let dataBytes = data.toOpenArrayByte(0, data.len - 1).toSeq()
# Store in CAS and get hash
let storeResult = builder.cas.storeObject(dataBytes)
if not storeResult.isOk:
return types_fixed.err[NpkPackage, string]("Failed to store file " & file & " in CAS")
let casObj = storeResult.okValue
let info = getFileInfo(fullPath)
files.add(PackageFile(
path: file,
hash: casObj.hash,
hashAlgorithm: "blake2b",
permissions: FilePermissions(
mode: cast[int](info.permissions),
owner: "root",
group: "root"
),
chunks: none(seq[types_fixed.ChunkRef])
))
totalSize += casObj.size
if files.len == 0:
return types_fixed.err[NpkPackage, string]("No files found in source directory")
# Calculate merkle root from file hashes
var allHashes = files.mapIt(it.hash).join("")
let merkleRoot = cas.calculateBlake2b(
allHashes.toOpenArrayByte(0, allHashes.len - 1).toSeq()
)
# Create package manifest
let manifest = PackageManifest(
files: files,
totalSize: totalSize,
created: now(),
merkleRoot: merkleRoot
)
# Create NPK package
var npk = NpkPackage(
metadata: Fragment(
id: PackageId(
name: name,
version: version,
stream: Stable
)
),
files: files,
manifest: manifest,
format: NpkBinary,
cryptoAlgorithms: getDefaultCryptoAlgorithms(NpkBinary)
)
return types_fixed.ok[NpkPackage, string](npk)
except Exception as e:
return types_fixed.err[NpkPackage, string]("Exception building package: " & e.msg)
proc signPackage*(builder: ArtifactBuilder, npk: var NpkPackage): types_fixed.Result[string, string] =
## Sign the package with Ed25519
if builder.config.keyId.len == 0:
return types_fixed.err[string, string]("No signing key configured")
try:
# Create signature payload from package metadata
let payload = npk.metadata.id.name &
npk.metadata.id.version &
npk.manifest.merkleRoot &
$npk.manifest.totalSize &
$npk.manifest.created
# Sign with Ed25519
let signature = builder.sigManager.sign(payload, builder.config.keyId)
# Add signature to package
npk.signature = some(Signature(
keyId: builder.config.keyId,
algorithm: "Ed25519",
signature: signature.toOpenArrayByte(0, signature.len - 1).toSeq()
))
return types_fixed.ok[string, string](signature)
except SignatureError as e:
return types_fixed.err[string, string]("Signing failed: " & e.msg)
except Exception as e:
return types_fixed.err[string, string]("Exception signing package: " & e.msg)
proc createArchive*(builder: ArtifactBuilder, npk: NpkPackage): types_fixed.Result[string, string] =
## Create the .npk.zst archive file
try:
let archiveName = fmt"{npk.metadata.id.name}-{npk.metadata.id.version}.npk.zst"
let archivePath = builder.outputDir / archiveName
let createResult = createNpkArchive(npk, archivePath, NpkZst)
if types_fixed.isErr(createResult):
return types_fixed.err[string, string]("Failed to create archive: " & types_fixed.getError(createResult).msg)
return types_fixed.ok[string, string](archivePath)
except Exception as e:
return types_fixed.err[string, string]("Exception creating archive: " & e.msg)
# =============================================================================
# Repository Upload
# =============================================================================
proc uploadToRepository*(builder: ArtifactBuilder,
archivePath: string,
remoteManager: RemoteManager): Future[PublishResult] {.async.} =
## Upload package to configured repository
var result = PublishResult(
success: false,
packageName: archivePath.extractFilename().split("-")[0]
)
if builder.config.dryRun:
result.success = true
result.repoUrl = "[dry-run]"
return result
try:
# Get active repository
let repoOpt = remoteManager.getRepository(builder.config.repoId)
if repoOpt.isNone:
result.errors.add("Repository not found: " & builder.config.repoId)
return result
let repo = repoOpt.get()
# Read package data
let packageData = readFile(archivePath)
result.uploadedBytes = packageData.len.int64
# Upload to repository
let uploadUrl = repo.url / "api/v1/packages/upload"
let uploadResult = remoteManager.makeSecureRequest(
repo,
"api/v1/packages/upload",
HttpPost,
packageData
)
if not uploadResult.success:
result.errors.add("Upload failed: " & uploadResult.error)
return result
# Parse response for URL
let response = parseJson(uploadResult.value)
result.repoUrl = response["url"].getStr("")
result.casHash = response["hash"].getStr("")
result.success = true
except Exception as e:
result.errors.add("Exception uploading: " & e.msg)
return result
# =============================================================================
# High-Level Publish API
# =============================================================================
proc publish*(builder: ArtifactBuilder,
source: ArtifactSource,
name: string,
version: string): Future[PublishResult] {.async.} =
## Full publish pipeline: build, sign, archive, upload
var result = PublishResult(
success: false,
packageName: name,
version: version
)
# Step 1: Build package from source
var npkResult: types_fixed.Result[NpkPackage, string]
case source.kind:
of FromDirectory:
npkResult = builder.buildFromDirectory(source.sourceDir, name, version)
of FromCas:
# Validate all files exist in CAS
var files = source.files
var totalSize: int64 = 0
var valid = true
for file in files:
if not builder.cas.objectExists(file.hash):
result.errors.add("CAS object missing for file: " & file.path & " (" & file.hash & ")")
valid = false
else:
# Get size from CAS to ensure accuracy
# Note: We'd need retrieveObject or similar to get stats without fetching
# For now, we assume the caller provided valid info or we check basic existence
discard
if not valid:
return result
# Calculate merkle root
var allHashes = files.mapIt(it.hash).join("")
let merkleRoot = cas.calculateBlake2b(
allHashes.toOpenArrayByte(0, allHashes.len - 1).toSeq()
)
let manifest = PackageManifest(
files: files,
totalSize: 0, # TODO: Calculate total size from CAS objects
created: now(),
merkleRoot: merkleRoot
)
var npk = NpkPackage(
metadata: Fragment(
id: PackageId(name: name, version: version, stream: Stable)
),
files: files,
manifest: manifest,
format: NpkBinary,
cryptoAlgorithms: getDefaultCryptoAlgorithms(NpkBinary)
)
npkResult = types_fixed.ok[NpkPackage, string](npk)
of FromGraft:
let convertResult = packages.convertGraftToNpk(source.graftResult, builder.cas)
if types_fixed.isErr(convertResult):
npkResult = types_fixed.err[NpkPackage, string]("Graft conversion failed: " & types_fixed.getError(convertResult).msg)
else:
npkResult = types_fixed.ok[NpkPackage, string](types_fixed.get(convertResult))
if types_fixed.isErr(npkResult):
result.errors.add(types_fixed.getError(npkResult))
return result
var npk = types_fixed.get(npkResult)
# Step 2: Sign package (if enabled)
if builder.config.signPackage and builder.config.keyId.len > 0:
let signResult = builder.signPackage(npk)
if types_fixed.isErr(signResult):
result.errors.add(types_fixed.getError(signResult))
# Continue without signature (warning)
else:
result.signature = types_fixed.get(signResult)
# Step 3: Create archive
let archiveResult = builder.createArchive(npk)
if types_fixed.isErr(archiveResult):
result.errors.add(types_fixed.getError(archiveResult))
return result
let archivePath = types_fixed.get(archiveResult)
# Step 4: Store in CAS
let archiveData = readFile(archivePath)
let storeResult = builder.cas.storeObject(
archiveData.toOpenArrayByte(0, archiveData.len - 1).toSeq()
)
if storeResult.isOk:
result.casHash = storeResult.okValue.hash
# Step 5: Upload to repository (if configured)
if builder.config.repoId.len > 0:
let remoteConfig = getDefaultRemoteManagerConfig()
let remoteManager = newRemoteManager(remoteConfig)
let uploadResult = await builder.uploadToRepository(archivePath, remoteManager)
if not uploadResult.success:
result.errors.add(uploadResult.errors)
# Continue - we still have the local package
else:
result.repoUrl = uploadResult.repoUrl
result.uploadedBytes = uploadResult.uploadedBytes
result.success = true
return result
# =============================================================================
# Convenience Functions
# =============================================================================
proc publishDirectory*(sourceDir: string, name: string, version: string,
keyId: string = "", repoId: string = "",
dryRun: bool = false): Future[PublishResult] {.async.} =
## Convenience function to publish a directory as a package
let casRoot = expandTilde("~/.nip/cas")
let keysRoot = expandTilde("~/.nip/keys")
var builder = newArtifactBuilder(casRoot, keysRoot)
builder.config.keyId = keyId
builder.config.repoId = repoId
builder.config.dryRun = dryRun
let source = ArtifactSource(
kind: FromDirectory,
sourceDir: sourceDir
)
return await builder.publish(source, name, version)
proc getPublishInfo*(pubResult: PublishResult): string =
## Get human-readable publish result
var info = fmt"📦 Package: {pubResult.packageName} v{pubResult.version}" & "\n"
if pubResult.success:
info.add("✅ Status: Published\n")
else:
info.add("❌ Status: Failed\n")
if pubResult.casHash.len > 0:
info.add(fmt"🔑 CAS Hash: {pubResult.casHash}" & "\n")
if pubResult.signature.len > 0:
info.add("✍️ Signed: Yes\n")
if pubResult.repoUrl.len > 0:
info.add(fmt"🌐 Repository URL: {pubResult.repoUrl}" & "\n")
if pubResult.uploadedBytes > 0:
info.add(fmt"📊 Uploaded: {pubResult.uploadedBytes} bytes" & "\n")
if pubResult.errors.len > 0:
info.add("⚠️ Errors:\n")
for err in pubResult.errors:
info.add(" - " & err & "\n")
return info