433 lines
14 KiB
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
|