Some checks failed
particle-os CI / Test particle-os (push) Failing after 1s
particle-os CI / Integration Test (push) Has been skipped
particle-os CI / Security & Quality (push) Failing after 1s
Test particle-os Basic Functionality / test-basic (push) Failing after 1s
particle-os CI / Build and Release (push) Has been skipped
411 lines
11 KiB
Go
411 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"time"
|
||
|
||
builder "deb-bootc-image-builder/internal/builder"
|
||
"github.com/sirupsen/logrus"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
var (
|
||
verbose bool
|
||
workDir string
|
||
)
|
||
|
||
func main() {
|
||
rootCmd := &cobra.Command{
|
||
Use: "particle-os",
|
||
Short: "particle-os: Debian-based OS image builder",
|
||
Long: `particle-os is a Debian-native OS image builder that creates bootable images
|
||
from container images and recipes, similar to ublue-os but with Debian foundation.
|
||
|
||
Features:
|
||
• Build from YAML recipes (like BlueBuild)
|
||
• Container-first workflow
|
||
• Multiple output formats (raw, qcow2, vmdk, vdi)
|
||
• OSTree + bootupd integration
|
||
• Debian-native stages and tools`,
|
||
Version: "0.1.0",
|
||
}
|
||
|
||
// Global flags
|
||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging")
|
||
rootCmd.PersistentFlags().StringVarP(&workDir, "work-dir", "w", "", "Working directory for builds")
|
||
|
||
// Add commands
|
||
rootCmd.AddCommand(buildCmd())
|
||
rootCmd.AddCommand(listCmd())
|
||
rootCmd.AddCommand(validateCmd())
|
||
rootCmd.AddCommand(versionCmd())
|
||
rootCmd.AddCommand(containerCmd())
|
||
|
||
// Execute
|
||
if err := rootCmd.Execute(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func buildCmd() *cobra.Command {
|
||
var output string
|
||
var clean bool
|
||
var jsonOutput bool
|
||
var quiet bool
|
||
|
||
cmd := &cobra.Command{
|
||
Use: "build [RECIPE_PATH]",
|
||
Short: "Build OS image from recipe",
|
||
Long: `Build an OS image from a particle-os recipe file.
|
||
|
||
Examples:
|
||
particle-os build recipes/debian-desktop.yml
|
||
particle-os build --output my-image.img recipes/debian-server.yml
|
||
particle-os build --work-dir /tmp/custom-build recipes/debian-gaming.yml
|
||
particle-os build --json --quiet recipes/debian-server.yml # CI/CD mode`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
recipePath := args[0]
|
||
|
||
// Setup logging
|
||
if verbose {
|
||
logrus.SetLevel(logrus.DebugLevel)
|
||
} else if quiet {
|
||
logrus.SetLevel(logrus.ErrorLevel) // Only show errors in quiet mode
|
||
} else {
|
||
logrus.SetLevel(logrus.InfoLevel) // Default info level
|
||
}
|
||
|
||
// Load recipe
|
||
recipe, err := builder.LoadRecipe(recipePath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to load recipe: %w", err)
|
||
}
|
||
|
||
// Validate recipe
|
||
if err := recipe.Validate(); err != nil {
|
||
return fmt.Errorf("recipe validation failed: %w", err)
|
||
}
|
||
|
||
if !quiet {
|
||
fmt.Printf("✅ Recipe loaded: %s\n", recipe.Name)
|
||
fmt.Printf("📦 Base image: %s\n", recipe.BaseImage)
|
||
fmt.Printf("🔧 Stages: %d\n", len(recipe.Stages))
|
||
fmt.Printf("📤 Output formats: %v\n", recipe.Output.Formats)
|
||
}
|
||
|
||
// Create builder with appropriate log level
|
||
var logLevel logrus.Level
|
||
if verbose {
|
||
logLevel = logrus.DebugLevel
|
||
} else if quiet {
|
||
logLevel = logrus.ErrorLevel
|
||
} else {
|
||
logLevel = logrus.InfoLevel
|
||
}
|
||
|
||
imageBuilder := builder.NewBuilder(recipe, workDir, logLevel)
|
||
|
||
// Build image
|
||
if !quiet {
|
||
fmt.Println("\n🚀 Starting build...")
|
||
}
|
||
result, err := imageBuilder.Build()
|
||
if err != nil {
|
||
return fmt.Errorf("build failed: %w", err)
|
||
}
|
||
|
||
// Handle CI/CD output
|
||
if jsonOutput {
|
||
// Create CI/CD friendly output
|
||
buildResult := map[string]interface{}{
|
||
"success": true,
|
||
"recipe": recipe.Name,
|
||
"base_image": recipe.BaseImage,
|
||
"stages": len(recipe.Stages),
|
||
"output_formats": recipe.Output.Formats,
|
||
"image_path": result.ImagePath,
|
||
"work_directory": result.Metadata["work_dir"],
|
||
"build_time": time.Now().Format(time.RFC3339),
|
||
"exit_code": 0,
|
||
}
|
||
|
||
// Add output file info if specified
|
||
if output != "" {
|
||
if err := copyFile(result.ImagePath, output); err != nil {
|
||
buildResult["success"] = false
|
||
buildResult["error"] = err.Error()
|
||
buildResult["exit_code"] = 1
|
||
} else {
|
||
buildResult["output_file"] = output
|
||
}
|
||
}
|
||
|
||
// Output JSON
|
||
jsonData, _ := json.MarshalIndent(buildResult, "", " ")
|
||
fmt.Println(string(jsonData))
|
||
} else {
|
||
// Human-friendly output
|
||
if !quiet {
|
||
fmt.Printf("\n✅ Build completed successfully!\n")
|
||
fmt.Printf("📁 Image created: %s\n", result.ImagePath)
|
||
fmt.Printf("📊 Work directory: %s\n", result.Metadata["work_dir"])
|
||
}
|
||
|
||
// Copy to output if specified
|
||
if output != "" {
|
||
if err := copyFile(result.ImagePath, output); err != nil {
|
||
return fmt.Errorf("failed to copy to output: %w", err)
|
||
}
|
||
if !quiet {
|
||
fmt.Printf("📋 Copied to: %s\n", output)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cleanup if requested
|
||
if clean {
|
||
if err := imageBuilder.Cleanup(); err != nil {
|
||
if !quiet {
|
||
fmt.Printf("⚠️ Warning: cleanup failed: %v\n", err)
|
||
}
|
||
} else if !quiet {
|
||
fmt.Println("🧹 Cleanup completed")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
},
|
||
}
|
||
|
||
cmd.Flags().StringVarP(&output, "output", "o", "", "Output path for the image")
|
||
cmd.Flags().BoolVarP(&clean, "clean", "c", false, "Clean up work directory after build")
|
||
cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "Output results in JSON format (CI/CD friendly)")
|
||
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Suppress non-essential output (CI/CD friendly)")
|
||
|
||
return cmd
|
||
}
|
||
|
||
func listCmd() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "List available recipes",
|
||
Long: `List all available particle-os recipes in the recipes directory.
|
||
|
||
This command scans the recipes directory and shows all available recipe files
|
||
with their basic information.`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
recipesDir := "recipes"
|
||
if _, err := os.Stat(recipesDir); os.IsNotExist(err) {
|
||
fmt.Printf("📁 Recipes directory not found: %s\n", recipesDir)
|
||
fmt.Println("💡 Create some recipes first!")
|
||
return nil
|
||
}
|
||
|
||
entries, err := os.ReadDir(recipesDir)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read recipes directory: %w", err)
|
||
}
|
||
|
||
fmt.Printf("📚 Available recipes in %s:\n\n", recipesDir)
|
||
|
||
recipeCount := 0
|
||
for _, entry := range entries {
|
||
if entry.IsDir() || !isRecipeFile(entry.Name()) {
|
||
continue
|
||
}
|
||
|
||
recipePath := filepath.Join(recipesDir, entry.Name())
|
||
recipe, err := builder.LoadRecipe(recipePath)
|
||
if err != nil {
|
||
fmt.Printf("❌ %s: Failed to load (%v)\n", entry.Name(), err)
|
||
continue
|
||
}
|
||
|
||
fmt.Printf("📋 %s\n", entry.Name())
|
||
fmt.Printf(" Name: %s\n", recipe.Name)
|
||
fmt.Printf(" Description: %s\n", recipe.Description)
|
||
fmt.Printf(" Base Image: %s\n", recipe.BaseImage)
|
||
fmt.Printf(" Stages: %d\n", len(recipe.Stages))
|
||
fmt.Printf(" Output Formats: %v\n", recipe.Output.Formats)
|
||
fmt.Println()
|
||
|
||
recipeCount++
|
||
}
|
||
|
||
if recipeCount == 0 {
|
||
fmt.Println("💡 No recipes found. Create some recipe files first!")
|
||
} else {
|
||
fmt.Printf("📊 Total recipes: %d\n", recipeCount)
|
||
}
|
||
|
||
return nil
|
||
},
|
||
}
|
||
|
||
return cmd
|
||
}
|
||
|
||
func validateCmd() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "validate [RECIPE_PATH]",
|
||
Short: "Validate a particle-os recipe",
|
||
Long: `Validate a particle-os recipe file for syntax and configuration errors.
|
||
|
||
This command checks the recipe file for:
|
||
• YAML syntax validity
|
||
• Required fields presence
|
||
• Stage configuration correctness
|
||
• Output format specification`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
recipePath := args[0]
|
||
|
||
fmt.Printf("🔍 Validating recipe: %s\n\n", recipePath)
|
||
|
||
// Load recipe
|
||
recipe, err := builder.LoadRecipe(recipePath)
|
||
if err != nil {
|
||
return fmt.Errorf("❌ Recipe loading failed: %w", err)
|
||
}
|
||
|
||
fmt.Println("✅ Recipe loaded successfully")
|
||
|
||
// Validate recipe
|
||
if err := recipe.Validate(); err != nil {
|
||
return fmt.Errorf("❌ Recipe validation failed: %w", err)
|
||
}
|
||
|
||
fmt.Println("✅ Recipe validation passed")
|
||
|
||
// Show recipe details
|
||
fmt.Printf("\n📋 Recipe Details:\n")
|
||
fmt.Printf(" Name: %s\n", recipe.Name)
|
||
fmt.Printf(" Description: %s\n", recipe.Description)
|
||
fmt.Printf(" Base Image: %s\n", recipe.BaseImage)
|
||
fmt.Printf(" Image Version: %s\n", recipe.ImageVersion)
|
||
fmt.Printf(" Stages: %d\n", len(recipe.Stages))
|
||
fmt.Printf(" Output Formats: %v\n", recipe.Output.Formats)
|
||
fmt.Printf(" Output Size: %s\n", recipe.Output.Size)
|
||
|
||
// Show stages
|
||
fmt.Printf("\n🔧 Stages:\n")
|
||
for i, stage := range recipe.Stages {
|
||
fmt.Printf(" %d. %s\n", i+1, stage.Type)
|
||
if len(stage.Options) > 0 {
|
||
fmt.Printf(" Options: %v\n", stage.Options)
|
||
}
|
||
}
|
||
|
||
fmt.Printf("\n🎉 Recipe is valid and ready to build!\n")
|
||
return nil
|
||
},
|
||
}
|
||
|
||
return cmd
|
||
}
|
||
|
||
func versionCmd() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "version",
|
||
Short: "Show particle-os version",
|
||
Run: func(cmd *cobra.Command, args []string) {
|
||
fmt.Println("particle-os v0.1.0")
|
||
fmt.Println("Debian-native OS image builder")
|
||
fmt.Println("Built with ❤️ for the Debian community")
|
||
},
|
||
}
|
||
|
||
return cmd
|
||
}
|
||
|
||
func containerCmd() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "container [IMAGE_REF]",
|
||
Short: "Show container image information",
|
||
Long: `Show detailed information about a container image.
|
||
|
||
This command inspects a container image and displays:
|
||
• Image ID and digest
|
||
• Size and creation date
|
||
• Architecture and OS
|
||
• Labels and metadata
|
||
• Layer information`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
imageRef := args[0]
|
||
|
||
fmt.Printf("🔍 Inspecting container: %s\n\n", imageRef)
|
||
|
||
// Create container processor
|
||
processor := builder.NewContainerProcessor("/tmp/particle-os-container-info", logrus.InfoLevel)
|
||
|
||
// Check availability
|
||
if !processor.IsAvailable() {
|
||
return fmt.Errorf("no container runtime (podman/docker) available")
|
||
}
|
||
|
||
fmt.Printf("📦 Container runtime: %s\n", processor.GetContainerRuntime())
|
||
|
||
// Get container info
|
||
info, err := processor.GetContainerInfo(imageRef)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get container info: %w", err)
|
||
}
|
||
|
||
// Display info
|
||
fmt.Printf("\n📋 Container Information:\n")
|
||
fmt.Printf(" ID: %s\n", info.ID)
|
||
fmt.Printf(" Digest: %s\n", info.Digest)
|
||
fmt.Printf(" Size: %d bytes (%.2f MB)\n", info.Size, float64(info.Size)/1024/1024)
|
||
fmt.Printf(" Created: %s\n", info.Created)
|
||
fmt.Printf(" OS: %s\n", info.OS)
|
||
fmt.Printf(" Architecture: %s\n", info.Arch)
|
||
fmt.Printf(" Variant: %s\n", info.Variant)
|
||
|
||
if len(info.Labels) > 0 {
|
||
fmt.Printf("\n🏷️ Labels:\n")
|
||
for k, v := range info.Labels {
|
||
fmt.Printf(" %s: %s\n", k, v)
|
||
}
|
||
}
|
||
|
||
if len(info.Layers) > 0 {
|
||
fmt.Printf("\n📚 Layers: %d\n", len(info.Layers))
|
||
}
|
||
|
||
fmt.Printf("\n✅ Container inspection completed\n")
|
||
return nil
|
||
},
|
||
}
|
||
|
||
return cmd
|
||
}
|
||
|
||
// Helper functions
|
||
|
||
func isRecipeFile(filename string) bool {
|
||
ext := filepath.Ext(filename)
|
||
return ext == ".yml" || ext == ".yaml"
|
||
}
|
||
|
||
func copyFile(src, dst string) error {
|
||
sourceFile, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer sourceFile.Close()
|
||
|
||
destFile, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer destFile.Close()
|
||
|
||
_, err = destFile.ReadFrom(sourceFile)
|
||
return err
|
||
}
|