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 }