## 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