415 lines
12 KiB
Plaintext
415 lines
12 KiB
Plaintext
|
|
# OCI Packaging Tool
|
||
|
|
# Package extensions as OCI artifacts for distribution
|
||
|
|
# Version: 1.0.0
|
||
|
|
|
||
|
|
use ../core/nulib/lib_provisioning/config/loader.nu get-config
|
||
|
|
use ../core/nulib/lib_provisioning/oci/client.nu *
|
||
|
|
use std log
|
||
|
|
|
||
|
|
# Package extension as OCI artifact
|
||
|
|
export def package-extension [
|
||
|
|
extension_path: string # Path to extension directory
|
||
|
|
--output-path: string # Output path for OCI artifact (optional)
|
||
|
|
--validate # Validate before packaging
|
||
|
|
] -> string {
|
||
|
|
log info $"Packaging extension from ($extension_path)"
|
||
|
|
|
||
|
|
# Validate extension structure
|
||
|
|
if $validate {
|
||
|
|
let validation = (validate-extension $extension_path)
|
||
|
|
|
||
|
|
if not $validation.valid {
|
||
|
|
error make {
|
||
|
|
msg: $"Extension validation failed: ($validation.errors)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Read extension manifest
|
||
|
|
let manifest_path = ($extension_path | path join "manifest.yaml")
|
||
|
|
|
||
|
|
if not ($manifest_path | path exists) {
|
||
|
|
error make {
|
||
|
|
msg: $"Missing manifest.yaml in ($extension_path)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let manifest = (open $manifest_path | from yaml)
|
||
|
|
|
||
|
|
# Create temporary directory for artifact
|
||
|
|
let temp_dir = (mktemp -d -t "oci-pkg-XXXXXX")
|
||
|
|
let artifact_dir = ($temp_dir | path join $manifest.name)
|
||
|
|
|
||
|
|
mkdir $artifact_dir
|
||
|
|
|
||
|
|
# Copy extension files to artifact directory
|
||
|
|
log info "Copying extension files..."
|
||
|
|
|
||
|
|
# Required directories
|
||
|
|
let required_dirs = ["kcl", "scripts"]
|
||
|
|
for dir in $required_dirs {
|
||
|
|
let src = ($extension_path | path join $dir)
|
||
|
|
if ($src | path exists) {
|
||
|
|
cp -r $src ($artifact_dir | path join $dir)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Optional directories
|
||
|
|
let optional_dirs = ["templates", "docs", "tests"]
|
||
|
|
for dir in $optional_dirs {
|
||
|
|
let src = ($extension_path | path join $dir)
|
||
|
|
if ($src | path exists) {
|
||
|
|
cp -r $src ($artifact_dir | path join $dir)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Copy manifest
|
||
|
|
cp $manifest_path ($artifact_dir | path join "manifest.yaml")
|
||
|
|
|
||
|
|
# Create OCI manifest
|
||
|
|
let oci_manifest = (create-oci-manifest $artifact_dir $manifest)
|
||
|
|
|
||
|
|
# Save OCI manifest
|
||
|
|
$oci_manifest | to json | save -f ($artifact_dir | path join "oci-manifest.json")
|
||
|
|
|
||
|
|
# Create tarball
|
||
|
|
let output_tar = if ($output_path | is-not-empty) {
|
||
|
|
$output_path
|
||
|
|
} else {
|
||
|
|
$"($manifest.name)-($manifest.version).tar.gz"
|
||
|
|
}
|
||
|
|
|
||
|
|
log info $"Creating tarball: ($output_tar)"
|
||
|
|
|
||
|
|
cd $temp_dir
|
||
|
|
tar czf $output_tar $manifest.name
|
||
|
|
|
||
|
|
# Move tarball to current directory
|
||
|
|
let final_path = ($env.PWD | path join ($output_tar | path basename))
|
||
|
|
mv $output_tar $final_path
|
||
|
|
|
||
|
|
# Cleanup
|
||
|
|
rm -rf $temp_dir
|
||
|
|
|
||
|
|
log info $"✓ Package created: ($final_path)"
|
||
|
|
|
||
|
|
$final_path
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate extension structure before packaging
|
||
|
|
export def validate-extension [
|
||
|
|
extension_path: string
|
||
|
|
] -> record {
|
||
|
|
log info $"Validating extension at ($extension_path)"
|
||
|
|
|
||
|
|
mut errors = []
|
||
|
|
mut warnings = []
|
||
|
|
|
||
|
|
# Check if directory exists
|
||
|
|
if not ($extension_path | path exists) {
|
||
|
|
$errors = ($errors | append "Extension directory does not exist")
|
||
|
|
return {
|
||
|
|
valid: false
|
||
|
|
errors: $errors
|
||
|
|
warnings: $warnings
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check for manifest.yaml
|
||
|
|
let manifest_path = ($extension_path | path join "manifest.yaml")
|
||
|
|
if not ($manifest_path | path exists) {
|
||
|
|
$errors = ($errors | append "Missing manifest.yaml")
|
||
|
|
} else {
|
||
|
|
# Validate manifest content
|
||
|
|
try {
|
||
|
|
let manifest = (open $manifest_path | from yaml)
|
||
|
|
|
||
|
|
# Required fields
|
||
|
|
let required_fields = ["name", "type", "version"]
|
||
|
|
for field in $required_fields {
|
||
|
|
if ($manifest | get -i $field | is-empty) {
|
||
|
|
$errors = ($errors | append $"Missing required field in manifest: ($field)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate type
|
||
|
|
let valid_types = ["provider", "taskserv", "cluster"]
|
||
|
|
if not ($manifest.type in $valid_types) {
|
||
|
|
$errors = ($errors | append $"Invalid type in manifest: ($manifest.type)")
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate version format (basic semver check)
|
||
|
|
if ($manifest.version? | is-not-empty) {
|
||
|
|
if ($manifest.version | str contains ".") == false {
|
||
|
|
$warnings = ($warnings | append "Version should follow semver format (x.y.z)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch { |err|
|
||
|
|
$errors = ($errors | append $"Invalid manifest.yaml: ($err.msg)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check for required directories
|
||
|
|
let kcl_dir = ($extension_path | path join "kcl")
|
||
|
|
if not ($kcl_dir | path exists) {
|
||
|
|
$errors = ($errors | append "Missing kcl/ directory")
|
||
|
|
} else {
|
||
|
|
# Check for kcl.mod
|
||
|
|
let kcl_mod = ($kcl_dir | path join "kcl.mod")
|
||
|
|
if not ($kcl_mod | path exists) {
|
||
|
|
$warnings = ($warnings | append "Missing kcl/kcl.mod (recommended)")
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check for at least one .k file
|
||
|
|
let k_files = (ls $kcl_dir | where name =~ "\.k$")
|
||
|
|
if ($k_files | is-empty) {
|
||
|
|
$errors = ($errors | append "No .k files found in kcl/ directory")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check for scripts directory
|
||
|
|
let scripts_dir = ($extension_path | path join "scripts")
|
||
|
|
if not ($scripts_dir | path exists) {
|
||
|
|
$warnings = ($warnings | append "Missing scripts/ directory (recommended)")
|
||
|
|
} else {
|
||
|
|
# Check for install.nu
|
||
|
|
let install_script = ($scripts_dir | path join "install.nu")
|
||
|
|
if not ($install_script | path exists) {
|
||
|
|
$warnings = ($warnings | append "Missing scripts/install.nu (recommended)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check for optional but recommended directories
|
||
|
|
let docs_dir = ($extension_path | path join "docs")
|
||
|
|
if not ($docs_dir | path exists) {
|
||
|
|
$warnings = ($warnings | append "Missing docs/ directory (recommended)")
|
||
|
|
}
|
||
|
|
|
||
|
|
let readme = ($extension_path | path join "README.md")
|
||
|
|
if not ($readme | path exists) {
|
||
|
|
$warnings = ($warnings | append "Missing README.md (recommended)")
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
valid: ($errors | is-empty)
|
||
|
|
errors: $errors
|
||
|
|
warnings: $warnings
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create OCI manifest for extension
|
||
|
|
export def create-oci-manifest [
|
||
|
|
artifact_path: string
|
||
|
|
extension_manifest: record
|
||
|
|
] -> record {
|
||
|
|
log info "Creating OCI manifest..."
|
||
|
|
|
||
|
|
# Calculate layer sizes
|
||
|
|
let layers = (ls -a $artifact_path | each { |item|
|
||
|
|
{
|
||
|
|
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip"
|
||
|
|
size: $item.size
|
||
|
|
digest: $"sha256:(echo $item.name | hash sha256)"
|
||
|
|
annotations: {
|
||
|
|
"org.opencontainers.image.title": $item.name
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
# Create config
|
||
|
|
let config = {
|
||
|
|
mediaType: "application/vnd.kcl.package.config.v1+json"
|
||
|
|
size: 0 # Will be calculated
|
||
|
|
digest: "" # Will be calculated
|
||
|
|
data: $extension_manifest
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create manifest
|
||
|
|
{
|
||
|
|
schemaVersion: 2
|
||
|
|
mediaType: "application/vnd.oci.image.manifest.v1+json"
|
||
|
|
config: $config
|
||
|
|
layers: $layers
|
||
|
|
annotations: {
|
||
|
|
"org.opencontainers.image.created": (date now | format date "%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
"org.opencontainers.image.title": $extension_manifest.name
|
||
|
|
"org.opencontainers.image.description": ($extension_manifest.description? | default "")
|
||
|
|
"org.opencontainers.image.version": $extension_manifest.version
|
||
|
|
"org.opencontainers.image.authors": ($extension_manifest.author? | default "")
|
||
|
|
"org.opencontainers.image.licenses": ($extension_manifest.license? | default "MIT")
|
||
|
|
"provisioning.extension.type": $extension_manifest.type
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Build and push extension to OCI registry
|
||
|
|
export def publish-extension [
|
||
|
|
extension_path: string
|
||
|
|
registry: string
|
||
|
|
namespace: string
|
||
|
|
--version: string # Override version from manifest
|
||
|
|
--insecure
|
||
|
|
--validate
|
||
|
|
] -> bool {
|
||
|
|
log info $"Publishing extension from ($extension_path)"
|
||
|
|
|
||
|
|
# Validate extension
|
||
|
|
if $validate {
|
||
|
|
let validation = (validate-extension $extension_path)
|
||
|
|
|
||
|
|
if not $validation.valid {
|
||
|
|
log error $"Validation failed: ($validation.errors)"
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
if not ($validation.warnings | is-empty) {
|
||
|
|
log warning $"Validation warnings:"
|
||
|
|
for warn in $validation.warnings {
|
||
|
|
log warning $" - ($warn)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Read manifest
|
||
|
|
let manifest_path = ($extension_path | path join "manifest.yaml")
|
||
|
|
let manifest = (open $manifest_path | from yaml)
|
||
|
|
|
||
|
|
let ext_version = if ($version | is-not-empty) {
|
||
|
|
$version
|
||
|
|
} else {
|
||
|
|
$manifest.version
|
||
|
|
}
|
||
|
|
|
||
|
|
log info $"Publishing ($manifest.name):($ext_version) to ($registry)/($namespace)"
|
||
|
|
|
||
|
|
# Push to OCI registry
|
||
|
|
let result = (push-artifact $extension_path $registry $namespace $manifest.name $ext_version --insecure=$insecure)
|
||
|
|
|
||
|
|
if $result {
|
||
|
|
log info $"✓ Successfully published ($manifest.name):($ext_version)"
|
||
|
|
log info $" Reference: ($registry)/($namespace)/($manifest.name):($ext_version)"
|
||
|
|
true
|
||
|
|
} else {
|
||
|
|
log error $"Failed to publish ($manifest.name):($ext_version)"
|
||
|
|
false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Unpack OCI artifact to local directory
|
||
|
|
export def unpack-extension [
|
||
|
|
artifact_path: string # Path to .tar.gz OCI artifact
|
||
|
|
destination: string # Destination directory
|
||
|
|
] -> string {
|
||
|
|
log info $"Unpacking OCI artifact from ($artifact_path)"
|
||
|
|
|
||
|
|
if not ($artifact_path | path exists) {
|
||
|
|
error make {
|
||
|
|
msg: $"Artifact not found: ($artifact_path)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create destination directory
|
||
|
|
mkdir $destination
|
||
|
|
|
||
|
|
# Extract tarball
|
||
|
|
log info $"Extracting to ($destination)"
|
||
|
|
|
||
|
|
tar xzf $artifact_path -C $destination
|
||
|
|
|
||
|
|
# Find the extension directory (should be only one)
|
||
|
|
let ext_dirs = (ls $destination | where type == "dir")
|
||
|
|
|
||
|
|
if ($ext_dirs | length) != 1 {
|
||
|
|
error make {
|
||
|
|
msg: "Artifact should contain exactly one directory"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let ext_path = ($ext_dirs | first | get name)
|
||
|
|
|
||
|
|
log info $"✓ Extension unpacked to ($ext_path)"
|
||
|
|
|
||
|
|
$ext_path
|
||
|
|
}
|
||
|
|
|
||
|
|
# List contents of OCI artifact without extracting
|
||
|
|
export def inspect-artifact [
|
||
|
|
artifact_path: string
|
||
|
|
] -> record {
|
||
|
|
log info $"Inspecting OCI artifact: ($artifact_path)"
|
||
|
|
|
||
|
|
if not ($artifact_path | path exists) {
|
||
|
|
error make {
|
||
|
|
msg: $"Artifact not found: ($artifact_path)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# List tarball contents
|
||
|
|
let contents = (tar tzf $artifact_path | lines)
|
||
|
|
|
||
|
|
# Try to extract and read manifest
|
||
|
|
let temp_dir = (mktemp -d -t "oci-inspect-XXXXXX")
|
||
|
|
|
||
|
|
tar xzf $artifact_path -C $temp_dir
|
||
|
|
|
||
|
|
# Find manifest.yaml
|
||
|
|
let manifest_files = (ls $temp_dir/**/manifest.yaml -r | where type == "file")
|
||
|
|
|
||
|
|
let manifest = if not ($manifest_files | is-empty) {
|
||
|
|
let manifest_path = ($manifest_files | first | get name)
|
||
|
|
open $manifest_path | from yaml
|
||
|
|
} else {
|
||
|
|
{}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Cleanup
|
||
|
|
rm -rf $temp_dir
|
||
|
|
|
||
|
|
{
|
||
|
|
artifact: ($artifact_path | path basename)
|
||
|
|
size: (ls $artifact_path | first | get size)
|
||
|
|
files: $contents
|
||
|
|
manifest: $manifest
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Generate manifest template for extension
|
||
|
|
export def generate-manifest [
|
||
|
|
extension_name: string
|
||
|
|
extension_type: string # provider, taskserv, cluster
|
||
|
|
--version: string = "0.1.0"
|
||
|
|
--description: string
|
||
|
|
--author: string
|
||
|
|
] -> record {
|
||
|
|
{
|
||
|
|
name: $extension_name
|
||
|
|
type: $extension_type
|
||
|
|
version: $version
|
||
|
|
description: ($description | default $"($extension_type) extension for ($extension_name)")
|
||
|
|
author: ($author | default $env.USER)
|
||
|
|
license: "MIT"
|
||
|
|
platforms: ["linux/amd64"]
|
||
|
|
dependencies: {}
|
||
|
|
tags: []
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Save manifest template to file
|
||
|
|
export def "generate-manifest save" [
|
||
|
|
extension_name: string
|
||
|
|
extension_type: string
|
||
|
|
output_path: string
|
||
|
|
--version: string = "0.1.0"
|
||
|
|
--description: string
|
||
|
|
--author: string
|
||
|
|
] -> nothing {
|
||
|
|
let manifest = (generate-manifest $extension_name $extension_type
|
||
|
|
--version $version
|
||
|
|
--description $description
|
||
|
|
--author $author)
|
||
|
|
|
||
|
|
$manifest | to yaml | save -f $output_path
|
||
|
|
|
||
|
|
log info $"✓ Manifest template saved to ($output_path)"
|
||
|
|
}
|