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
1411 lines
43 KiB
Go
1411 lines
43 KiB
Go
package particle_os
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// Builder builds OS images from particle-os recipes
|
|
type Builder struct {
|
|
recipe *Recipe
|
|
workDir string
|
|
logger *logrus.Logger
|
|
buildID string
|
|
artifacts map[string]string
|
|
container *ContainerProcessor
|
|
packages *PackageManager
|
|
loopDevice string
|
|
}
|
|
|
|
// BuildResult represents the result of a build
|
|
type BuildResult struct {
|
|
Success bool
|
|
ImagePath string
|
|
Logs []string
|
|
Errors []string
|
|
Metadata map[string]interface{}
|
|
}
|
|
|
|
// NewBuilder creates a new recipe builder
|
|
func NewBuilder(recipe *Recipe, workDir string, logLevel logrus.Level) *Builder {
|
|
if workDir == "" {
|
|
workDir = "/tmp/particle-os-build"
|
|
}
|
|
|
|
logger := logrus.New()
|
|
logger.SetLevel(logLevel)
|
|
|
|
return &Builder{
|
|
recipe: recipe,
|
|
workDir: workDir,
|
|
logger: logger,
|
|
artifacts: make(map[string]string),
|
|
container: NewContainerProcessor(workDir, logLevel),
|
|
packages: nil, // Will be initialized after container extraction
|
|
}
|
|
}
|
|
|
|
// Build executes the recipe and builds the OS image
|
|
func (b *Builder) Build() (*BuildResult, error) {
|
|
b.logger.Info("Starting particle-os build")
|
|
b.logger.Infof("Recipe: %s", b.recipe.Name)
|
|
b.logger.Infof("Base image: %s", b.recipe.BaseImage)
|
|
|
|
// Create work directory
|
|
if err := b.setupWorkDir(); err != nil {
|
|
return nil, fmt.Errorf("failed to setup work directory: %w", err)
|
|
}
|
|
|
|
// Validate recipe
|
|
if err := b.recipe.Validate(); err != nil {
|
|
return nil, fmt.Errorf("recipe validation failed: %w", err)
|
|
}
|
|
|
|
// Extract base container
|
|
if err := b.extractBaseContainer(); err != nil {
|
|
return nil, fmt.Errorf("failed to extract base container: %w", err)
|
|
}
|
|
|
|
// Execute stages
|
|
if err := b.executeStages(); err != nil {
|
|
// Enhanced error reporting for stage execution failures
|
|
b.logger.Errorf("Build failed during stage execution:")
|
|
b.logger.Errorf(" Recipe: %s", b.recipe.Name)
|
|
b.logger.Errorf(" Base image: %s", b.recipe.BaseImage)
|
|
b.logger.Errorf(" Work directory: %s", b.workDir)
|
|
b.logger.Errorf(" Error: %v", err)
|
|
|
|
// Provide helpful debugging information
|
|
b.logger.Errorf("Debugging tips:")
|
|
b.logger.Errorf(" 1. Check work directory: %s", b.workDir)
|
|
b.logger.Errorf(" 2. Review stage logs in: %s/stages/", b.workDir)
|
|
b.logger.Errorf(" 3. Verify sudo access for file operations")
|
|
b.logger.Errorf(" 4. Check available disk space")
|
|
|
|
return nil, fmt.Errorf("stage execution failed: %w", err)
|
|
}
|
|
|
|
// Create final image
|
|
imagePath, err := b.createFinalImage()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create final image: %w", err)
|
|
}
|
|
|
|
b.logger.Info("Build completed successfully")
|
|
b.logger.Infof("Output image: %s", imagePath)
|
|
|
|
return &BuildResult{
|
|
Success: true,
|
|
ImagePath: imagePath,
|
|
Metadata: map[string]interface{}{
|
|
"recipe_name": b.recipe.Name,
|
|
"base_image": b.recipe.BaseImage,
|
|
"work_dir": b.workDir,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// setupWorkDir creates and prepares the working directory
|
|
func (b *Builder) setupWorkDir() error {
|
|
// Check available disk space before proceeding
|
|
if err := b.checkDiskSpace(); err != nil {
|
|
return fmt.Errorf("insufficient disk space: %w", err)
|
|
}
|
|
|
|
// Create work directory
|
|
if err := os.MkdirAll(b.workDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create work directory: %w", err)
|
|
}
|
|
|
|
// Create subdirectories
|
|
dirs := []string{
|
|
filepath.Join(b.workDir, "stages"),
|
|
filepath.Join(b.workDir, "artifacts"),
|
|
filepath.Join(b.workDir, "cache"),
|
|
filepath.Join(b.workDir, "output"),
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
b.logger.Infof("Work directory created: %s", b.workDir)
|
|
return nil
|
|
}
|
|
|
|
// checkDiskSpace verifies sufficient disk space is available for the build
|
|
func (b *Builder) checkDiskSpace() error {
|
|
// Get disk space information for the work directory
|
|
workDirPath := b.workDir
|
|
if workDirPath == "" {
|
|
// Default to /tmp if no work directory specified
|
|
workDirPath = "/tmp"
|
|
}
|
|
|
|
// Get absolute path
|
|
absPath, err := filepath.Abs(workDirPath)
|
|
if err != nil {
|
|
// If we can't get absolute path, use original
|
|
absPath = workDirPath
|
|
}
|
|
|
|
// Get disk space info
|
|
var stat unix.Statfs_t
|
|
if err := unix.Statfs(absPath, &stat); err != nil {
|
|
// If we can't check disk space, log warning but continue
|
|
b.logger.Warnf("Could not check disk space for %s: %v", absPath, err)
|
|
return nil
|
|
}
|
|
|
|
// Calculate available space in GB
|
|
availableGB := (stat.Bavail * uint64(stat.Bsize)) / (1024 * 1024 * 1024)
|
|
|
|
// Require at least 5GB available for builds
|
|
requiredGB := uint64(5)
|
|
|
|
if availableGB < requiredGB {
|
|
return fmt.Errorf("insufficient disk space: %d GB available, %d GB required", availableGB, requiredGB)
|
|
}
|
|
|
|
b.logger.Infof("Disk space check passed: %d GB available", availableGB)
|
|
return nil
|
|
}
|
|
|
|
// executeStages runs all stages in the recipe
|
|
func (b *Builder) executeStages() error {
|
|
b.logger.Info("Executing recipe stages")
|
|
|
|
for i, stage := range b.recipe.Stages {
|
|
b.logger.Infof("Executing stage %d/%d: %s", i+1, len(b.recipe.Stages), stage.Type)
|
|
|
|
if err := b.executeStage(stage, i); err != nil {
|
|
// Enhanced error reporting with stage details
|
|
stageError := fmt.Errorf("stage %d (%s) failed: %w", i+1, stage.Type, err)
|
|
|
|
// Log detailed error information for debugging
|
|
b.logger.Errorf("Stage execution failed:")
|
|
b.logger.Errorf(" Stage: %d/%d (%s)", i+1, len(b.recipe.Stages), stage.Type)
|
|
b.logger.Errorf(" Error: %v", err)
|
|
|
|
// Log stage options for debugging
|
|
if len(stage.Options) > 0 {
|
|
b.logger.Errorf(" Stage options: %+v", stage.Options)
|
|
}
|
|
|
|
// Log work directory location for manual inspection
|
|
b.logger.Errorf(" Work directory: %s", b.workDir)
|
|
b.logger.Errorf(" Stage directory: %s/stages/%02d-%s", b.workDir, i, stage.Type)
|
|
|
|
return stageError
|
|
}
|
|
|
|
b.logger.Infof("Stage %d completed successfully", i+1)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeStage executes a single stage
|
|
func (b *Builder) executeStage(stage Stage, index int) error {
|
|
stageDir := filepath.Join(b.workDir, "stages", fmt.Sprintf("%02d-%s", index, stage.Type))
|
|
if err := os.MkdirAll(stageDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create stage directory: %w", err)
|
|
}
|
|
|
|
b.logger.Infof("Executing stage %d/%d: %s", index+1, len(b.recipe.Stages), stage.Type)
|
|
|
|
switch stage.Type {
|
|
case "org.osbuild.debian.apt":
|
|
return b.executeApt(stage, stageDir)
|
|
case "org.osbuild.debian.locale":
|
|
return b.executeLocale(stage, stageDir)
|
|
case "org.osbuild.debian.timezone":
|
|
return b.executeTimezone(stage, stageDir)
|
|
case "org.osbuild.debian.users":
|
|
return b.executeUsers(stage, stageDir)
|
|
case "org.osbuild.qemu":
|
|
return b.executeQEMU(stage, stageDir)
|
|
case "org.osbuild.debian.sources":
|
|
return b.executeSources(stage, stageDir)
|
|
case "org.osbuild.debian.ostree":
|
|
return b.executeOSTreeStage(stage, stageDir)
|
|
case "org.osbuild.debian.ostree_boot":
|
|
return b.executeOSTreeBootStage(stage, stageDir)
|
|
case "org.osbuild.ostree_deploy":
|
|
return b.executeOSTreeDeployStage(stage, stageDir)
|
|
case "org.osbuild.debian.bootc":
|
|
return b.executeBootc(stage, stageDir)
|
|
case "org.osbuild.debian.bootupd":
|
|
return b.executeBootupd(stage, stageDir)
|
|
case "org.osbuild.debian.kernel":
|
|
return b.executeKernel(stage, stageDir)
|
|
default:
|
|
return fmt.Errorf("unknown stage type: %s", stage.Type)
|
|
}
|
|
}
|
|
|
|
// executeDebootstrap runs the debootstrap stage
|
|
func (b *Builder) executeDebootstrap(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing debootstrap stage")
|
|
|
|
// Extract options
|
|
suite, _ := stage.Options["suite"].(string)
|
|
if suite == "" {
|
|
suite = "trixie"
|
|
}
|
|
|
|
arch, _ := stage.Options["arch"].(string)
|
|
if arch == "" {
|
|
arch = "amd64"
|
|
}
|
|
|
|
variant, _ := stage.Options["variant"].(string)
|
|
if variant == "" {
|
|
variant = "minbase"
|
|
}
|
|
|
|
components, _ := stage.Options["components"].([]interface{})
|
|
var componentStrings []string
|
|
for _, comp := range components {
|
|
if str, ok := comp.(string); ok {
|
|
componentStrings = append(componentStrings, str)
|
|
}
|
|
}
|
|
if len(componentStrings) == 0 {
|
|
componentStrings = []string{"main"}
|
|
}
|
|
|
|
// Use the package manager to create debootstrap system
|
|
if err := b.packages.CreateDebootstrap(suite, b.artifacts["rootfs"], arch, variant, componentStrings); err != nil {
|
|
return fmt.Errorf("debootstrap execution failed: %w", err)
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "debootstrap-completed")
|
|
if err := os.WriteFile(placeholder, []byte("debootstrap stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
b.artifacts["debootstrap"] = b.artifacts["rootfs"]
|
|
return nil
|
|
}
|
|
|
|
// executeApt runs the apt stage
|
|
func (b *Builder) executeApt(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing apt stage")
|
|
|
|
// Extract packages
|
|
packages, _ := stage.Options["packages"].([]interface{})
|
|
if len(packages) == 0 {
|
|
b.logger.Info("No packages specified, skipping apt stage")
|
|
return nil
|
|
}
|
|
|
|
// Convert packages to string slice
|
|
var packageStrings []string
|
|
for _, pkg := range packages {
|
|
if str, ok := pkg.(string); ok {
|
|
packageStrings = append(packageStrings, str)
|
|
}
|
|
}
|
|
|
|
// Extract other options
|
|
update, _ := stage.Options["update"].(bool)
|
|
clean, _ := stage.Options["clean"].(bool)
|
|
|
|
// Use the package manager to install packages
|
|
if err := b.packages.InstallPackages(packageStrings, update, clean); err != nil {
|
|
return fmt.Errorf("apt execution failed: %w", err)
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "apt-completed")
|
|
if err := os.WriteFile(placeholder, []byte("apt stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeSources runs the sources stage
|
|
func (b *Builder) executeSources(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing sources stage")
|
|
|
|
// Extract options
|
|
mirror, _ := stage.Options["mirror"].(string)
|
|
if mirror == "" {
|
|
mirror = "https://deb.debian.org/debian"
|
|
}
|
|
|
|
// Check for apt-cacher-ng override (optional enhancement)
|
|
if aptCacheURL := stage.Options["apt_cacher_ng"]; aptCacheURL != nil {
|
|
if url, ok := aptCacheURL.(string); ok && url != "" {
|
|
os.Setenv("APT_CACHER_NG_URL", url)
|
|
b.logger.Infof("apt-cacher-ng URL set from recipe: %s (optional enhancement)", url)
|
|
}
|
|
}
|
|
|
|
// Check for suite override
|
|
suite, _ := stage.Options["suite"].(string)
|
|
if suite == "" {
|
|
suite = "trixie" // Default to trixie (Debian 13)
|
|
}
|
|
|
|
components, _ := stage.Options["components"].([]interface{})
|
|
var componentStrings []string
|
|
for _, comp := range components {
|
|
if str, ok := comp.(string); ok {
|
|
componentStrings = append(componentStrings, str)
|
|
}
|
|
}
|
|
if len(componentStrings) == 0 {
|
|
componentStrings = []string{"main", "contrib", "non-free"}
|
|
}
|
|
|
|
additionalSources, _ := stage.Options["additional_sources"].([]interface{})
|
|
var additionalSourceStrings []string
|
|
for _, source := range additionalSources {
|
|
if str, ok := source.(string); ok {
|
|
additionalSourceStrings = append(additionalSourceStrings, str)
|
|
}
|
|
}
|
|
|
|
// Use the package manager to configure sources
|
|
if err := b.packages.ConfigureSources(mirror, componentStrings, additionalSourceStrings); err != nil {
|
|
return fmt.Errorf("sources configuration failed: %w", err)
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "sources-completed")
|
|
if err := os.WriteFile(placeholder, []byte("sources stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeUsers runs the users stage
|
|
func (b *Builder) executeUsers(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing users stage")
|
|
|
|
// Try to parse as structured UsersOptions first
|
|
if usersData, ok := stage.Options["users"]; ok {
|
|
// Convert to JSON and back to get proper structure
|
|
jsonData, err := json.Marshal(usersData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal users data: %w", err)
|
|
}
|
|
|
|
var usersOptions UsersOptions
|
|
if err := json.Unmarshal(jsonData, &usersOptions); err != nil {
|
|
return fmt.Errorf("failed to parse users options: %w", err)
|
|
}
|
|
|
|
// Process each user with proper types
|
|
for username, user := range usersOptions.Users {
|
|
b.logger.Infof("Creating user %s with UID: %d, GID: %d", username, user.UID, user.GID)
|
|
|
|
// Set defaults
|
|
shell := user.Shell
|
|
if shell == "" {
|
|
shell = "/bin/bash"
|
|
}
|
|
|
|
home := user.Home
|
|
if home == "" {
|
|
home = fmt.Sprintf("/home/%s", username)
|
|
}
|
|
|
|
// Create user using package manager
|
|
if err := b.packages.CreateUser(username, user.Password, shell, user.Groups, user.UID, user.GID, home, user.Comment); err != nil {
|
|
return fmt.Errorf("failed to create user %s: %w", username, err)
|
|
}
|
|
}
|
|
} else {
|
|
b.logger.Info("No users specified, skipping users stage")
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "users-completed")
|
|
if err := os.WriteFile(placeholder, []byte("users stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeLocale runs the locale stage
|
|
func (b *Builder) executeLocale(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing locale stage")
|
|
|
|
// Extract options
|
|
language, _ := stage.Options["language"].(string)
|
|
if language == "" {
|
|
language = "en_US.UTF-8"
|
|
}
|
|
|
|
defaultLocale, _ := stage.Options["default_locale"].(string)
|
|
if defaultLocale == "" {
|
|
defaultLocale = language
|
|
}
|
|
|
|
additionalLocales, _ := stage.Options["additional_locales"].([]interface{})
|
|
var additionalLocaleStrings []string
|
|
for _, loc := range additionalLocales {
|
|
if str, ok := loc.(string); ok {
|
|
additionalLocaleStrings = append(additionalLocaleStrings, str)
|
|
}
|
|
}
|
|
|
|
// Use the package manager to configure locale
|
|
if err := b.packages.ConfigureLocale(language, defaultLocale, additionalLocaleStrings); err != nil {
|
|
return fmt.Errorf("locale configuration failed: %w", err)
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "locale-completed")
|
|
if err := os.WriteFile(placeholder, []byte("locale stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeTimezone runs the timezone stage
|
|
func (b *Builder) executeTimezone(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing timezone stage")
|
|
|
|
// Extract options
|
|
timezone, _ := stage.Options["timezone"].(string)
|
|
if timezone == "" {
|
|
timezone = "UTC"
|
|
}
|
|
|
|
// Use the package manager to configure timezone
|
|
if err := b.packages.ConfigureTimezone(timezone); err != nil {
|
|
return fmt.Errorf("timezone configuration failed: %w", err)
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "timezone-completed")
|
|
if err := os.WriteFile(placeholder, []byte("timezone stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeOSTree runs the OSTree stage
|
|
func (b *Builder) executeOSTree(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing OSTree stage")
|
|
|
|
// TODO: Implement actual OSTree operations
|
|
placeholder := filepath.Join(stageDir, "ostree-completed")
|
|
if err := os.WriteFile(placeholder, []byte("ostree stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeBootupd runs the bootupd stage
|
|
func (b *Builder) executeBootupd(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing bootupd stage")
|
|
|
|
// Extract bootupd options
|
|
configFile, _ := stage.Options["config_file"].(string)
|
|
updateMethod, _ := stage.Options["update_method"].(string)
|
|
deploymentSource, _ := stage.Options["deployment_source"].(string)
|
|
autoUpdate, _ := stage.Options["auto_update"].(bool)
|
|
|
|
// Set defaults
|
|
if configFile == "" {
|
|
configFile = "/etc/bootupd/config.yaml"
|
|
}
|
|
if updateMethod == "" {
|
|
updateMethod = "atomic"
|
|
}
|
|
if deploymentSource == "" {
|
|
deploymentSource = "ostree"
|
|
}
|
|
|
|
b.logger.Infof("Bootupd configuration: config=%s, update=%s, source=%s, auto_update=%t",
|
|
configFile, updateMethod, deploymentSource, autoUpdate)
|
|
|
|
// Get rootfs directory from artifacts
|
|
rootfsDir, exists := b.artifacts["rootfs"]
|
|
if !exists {
|
|
return fmt.Errorf("rootfs not found in artifacts")
|
|
}
|
|
|
|
// Create bootupd configuration directory
|
|
bootupdConfigDir := filepath.Join(rootfsDir, "etc/bootupd")
|
|
if err := os.MkdirAll(bootupdConfigDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create bootupd config directory: %w", err)
|
|
}
|
|
|
|
// Write bootupd configuration file
|
|
bootupdConfig := fmt.Sprintf(`# Bootupd configuration for particle-os
|
|
update_method: %s
|
|
deployment_source: %s
|
|
auto_update: %t
|
|
ostree:
|
|
enabled: true
|
|
repository: /ostree/repo
|
|
branch: main
|
|
deployment_type: atomic
|
|
`, updateMethod, deploymentSource, autoUpdate)
|
|
|
|
bootupdConfigPath := filepath.Join(bootupdConfigDir, "bootupd.conf")
|
|
if err := os.WriteFile(bootupdConfigPath, []byte(bootupdConfig), 0644); err != nil {
|
|
return fmt.Errorf("failed to write bootupd config: %w", err)
|
|
}
|
|
b.logger.Info("Bootupd configuration written")
|
|
|
|
// Create bootupd systemd service directory
|
|
systemdDir := filepath.Join(rootfsDir, "etc/systemd/system")
|
|
if err := os.MkdirAll(systemdDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create systemd directory: %w", err)
|
|
}
|
|
|
|
// Write bootupd systemd service
|
|
bootupdService := `[Unit]
|
|
Description=Bootupd Update Management Service
|
|
After=network.target ostree-boot.service bootc.service
|
|
Wants=ostree-boot.service bootc.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
RemainAfterExit=yes
|
|
ExecStart=/usr/bin/bootupd status
|
|
ExecStart=/usr/bin/bootupd check-update
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
|
|
bootupdServicePath := filepath.Join(systemdDir, "bootupd.service")
|
|
if err := os.WriteFile(bootupdServicePath, []byte(bootupdService), 0644); err != nil {
|
|
return fmt.Errorf("failed to write bootupd service: %w", err)
|
|
}
|
|
b.logger.Info("Bootupd systemd service written")
|
|
|
|
// Create bootupd timer for auto-updates if enabled
|
|
if autoUpdate {
|
|
bootupdTimer := `[Unit]
|
|
Description=Bootupd Auto-Update Timer
|
|
After=network.target
|
|
|
|
[Timer]
|
|
OnBootSec=5min
|
|
OnUnitActiveSec=1h
|
|
RandomizedDelaySec=300
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
`
|
|
|
|
bootupdTimerPath := filepath.Join(systemdDir, "bootupd.timer")
|
|
if err := os.WriteFile(bootupdTimerPath, []byte(bootupdTimer), 0644); err != nil {
|
|
return fmt.Errorf("failed to write bootupd timer: %w", err)
|
|
}
|
|
b.logger.Info("Bootupd auto-update timer written")
|
|
}
|
|
|
|
// Create placeholder
|
|
placeholder := filepath.Join(stageDir, "bootupd-completed")
|
|
if err := os.WriteFile(placeholder, []byte("bootupd stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeBootc runs the bootc stage
|
|
func (b *Builder) executeBootc(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing bootc stage")
|
|
|
|
// Extract bootc options
|
|
configFile, _ := stage.Options["config_file"].(string)
|
|
deploymentType, _ := stage.Options["deployment_type"].(string)
|
|
bootMethod, _ := stage.Options["boot_method"].(string)
|
|
kernelArgs, _ := stage.Options["kernel_args"].(string)
|
|
|
|
// Set defaults
|
|
if configFile == "" {
|
|
configFile = "/etc/bootc/config.yaml"
|
|
}
|
|
if deploymentType == "" {
|
|
deploymentType = "atomic"
|
|
}
|
|
if bootMethod == "" {
|
|
bootMethod = "ostree"
|
|
}
|
|
|
|
b.logger.Infof("Bootc configuration: config=%s, deployment=%s, boot=%s, kernel_args=%s",
|
|
configFile, deploymentType, bootMethod, kernelArgs)
|
|
|
|
// Get rootfs directory from artifacts
|
|
rootfsDir, exists := b.artifacts["rootfs"]
|
|
if !exists {
|
|
return fmt.Errorf("rootfs not found in artifacts")
|
|
}
|
|
|
|
// Create bootc configuration directory
|
|
bootcConfigDir := filepath.Join(rootfsDir, "etc/bootc")
|
|
if err := os.MkdirAll(bootcConfigDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create bootc config directory: %w", err)
|
|
}
|
|
|
|
// Write bootc configuration file
|
|
bootcConfig := fmt.Sprintf(`# Bootc configuration for particle-os
|
|
deployment_type: %s
|
|
boot_method: %s
|
|
kernel_args: %s
|
|
ostree:
|
|
enabled: true
|
|
deployment_source: ostree
|
|
`, deploymentType, bootMethod, kernelArgs)
|
|
|
|
bootcConfigPath := filepath.Join(bootcConfigDir, "config.yaml")
|
|
if err := os.WriteFile(bootcConfigPath, []byte(bootcConfig), 0644); err != nil {
|
|
return fmt.Errorf("failed to write bootc config: %w", err)
|
|
}
|
|
b.logger.Info("Bootc configuration written")
|
|
|
|
// Create bootc systemd service directory
|
|
systemdDir := filepath.Join(rootfsDir, "etc/systemd/system")
|
|
if err := os.MkdirAll(systemdDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create systemd directory: %w", err)
|
|
}
|
|
|
|
// Write bootc systemd service
|
|
bootcService := `[Unit]
|
|
Description=Bootc Boot Management Service
|
|
After=network.target ostree-boot.service
|
|
Wants=ostree-boot.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
RemainAfterExit=yes
|
|
ExecStart=/usr/bin/bootc status
|
|
ExecStart=/usr/bin/bootc apply
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
|
|
bootcServicePath := filepath.Join(systemdDir, "bootc.service")
|
|
if err := os.WriteFile(bootcServicePath, []byte(bootcService), 0644); err != nil {
|
|
return fmt.Errorf("failed to write bootc service: %w", err)
|
|
}
|
|
b.logger.Info("Bootc systemd service written")
|
|
|
|
// Create placeholder
|
|
placeholder := filepath.Join(stageDir, "bootc-completed")
|
|
if err := os.WriteFile(placeholder, []byte("bootc stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeQEMU runs the QEMU stage
|
|
func (b *Builder) executeQEMU(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing QEMU stage")
|
|
|
|
// Extract options
|
|
formats, _ := stage.Options["formats"].([]interface{})
|
|
size, _ := stage.Options["size"].(string)
|
|
filename, _ := stage.Options["filename"].(string)
|
|
|
|
if len(formats) == 0 {
|
|
formats = []interface{}{"raw"}
|
|
}
|
|
if size == "" {
|
|
size = "5G"
|
|
}
|
|
if filename == "" {
|
|
filename = "particle-os"
|
|
}
|
|
|
|
// Convert formats to string slice
|
|
var formatStrings []string
|
|
for _, f := range formats {
|
|
if str, ok := f.(string); ok {
|
|
formatStrings = append(formatStrings, str)
|
|
}
|
|
}
|
|
|
|
// Create QEMU images for each format
|
|
for _, format := range formatStrings {
|
|
if err := b.createQEMUImage(format, size, filename, stageDir); err != nil {
|
|
return fmt.Errorf("failed to create %s image: %w", format, err)
|
|
}
|
|
}
|
|
|
|
placeholder := filepath.Join(stageDir, "qemu-completed")
|
|
if err := os.WriteFile(placeholder, []byte("qemu stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createQEMUImage creates a QEMU image in the specified format
|
|
func (b *Builder) createQEMUImage(format, size, filename, stageDir string) error {
|
|
b.logger.Infof("Creating QEMU image: %s format, %s size, filename: %s", format, size, filename)
|
|
|
|
// Parse size to bytes
|
|
sizeBytes, err := parseSize(size)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid size format: %w", err)
|
|
}
|
|
|
|
// Create output directory
|
|
outputDir := filepath.Join(b.workDir, "output")
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
|
|
// Generate output filename
|
|
outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, format))
|
|
|
|
// Create image based on format
|
|
switch format {
|
|
case "raw":
|
|
return b.createRawImage(outputFile, sizeBytes)
|
|
case "qcow2":
|
|
return b.createQcow2Image(outputFile, sizeBytes)
|
|
case "vmdk":
|
|
return b.createVmdkImage(outputFile, sizeBytes)
|
|
case "vdi":
|
|
return b.createVdiImage(outputFile, sizeBytes)
|
|
default:
|
|
return fmt.Errorf("unsupported format: %s", format)
|
|
}
|
|
}
|
|
|
|
// parseSize converts size string to bytes
|
|
func parseSize(size string) (int64, error) {
|
|
// Remove any whitespace
|
|
size = strings.TrimSpace(size)
|
|
|
|
// Handle common size suffixes
|
|
var multiplier int64 = 1
|
|
if strings.HasSuffix(size, "K") || strings.HasSuffix(size, "KB") {
|
|
multiplier = 1024
|
|
size = strings.TrimSuffix(strings.TrimSuffix(size, "KB"), "K")
|
|
} else if strings.HasSuffix(size, "M") || strings.HasSuffix(size, "MB") {
|
|
multiplier = 1024 * 1024
|
|
size = strings.TrimSuffix(strings.TrimSuffix(size, "MB"), "M")
|
|
} else if strings.HasSuffix(size, "G") || strings.HasSuffix(size, "GB") {
|
|
multiplier = 1024 * 1024 * 1024
|
|
size = strings.TrimSuffix(strings.TrimSuffix(size, "GB"), "G")
|
|
} else if strings.HasSuffix(size, "T") || strings.HasSuffix(size, "TB") {
|
|
multiplier = 1024 * 1024 * 1024 * 1024
|
|
size = strings.TrimSuffix(strings.TrimSuffix(size, "TB"), "T")
|
|
}
|
|
|
|
// Parse the numeric value
|
|
value, err := strconv.ParseInt(size, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid size value: %s", size)
|
|
}
|
|
|
|
return value * multiplier, nil
|
|
}
|
|
|
|
// createRawImage creates a raw disk image
|
|
func (b *Builder) createRawImage(outputFile string, sizeBytes int64) error {
|
|
// Check if qemu-img is available for proper image creation
|
|
if _, err := exec.LookPath("qemu-img"); err == nil {
|
|
// Use qemu-img to create a proper raw image
|
|
sizeMB := sizeBytes / (1024 * 1024)
|
|
cmd := exec.Command("qemu-img", "create", "-f", "raw", outputFile, fmt.Sprintf("%dM", sizeMB))
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
b.logger.Warn("qemu-img failed, falling back to sparse file")
|
|
} else {
|
|
b.logger.Infof("Created raw image with qemu-img: %s (%d bytes)", outputFile, sizeBytes)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Fallback: Create sparse file
|
|
file, err := os.Create(outputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create raw image file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Seek to the end to create sparse file
|
|
if _, err := file.Seek(sizeBytes-1, 0); err != nil {
|
|
return fmt.Errorf("failed to seek in raw image: %w", err)
|
|
}
|
|
|
|
// Write a single byte to allocate the file
|
|
if _, err := file.Write([]byte{0}); err != nil {
|
|
return fmt.Errorf("failed to write to raw image: %w", err)
|
|
}
|
|
|
|
b.logger.Infof("Created sparse raw image: %s (%d bytes)", outputFile, sizeBytes)
|
|
return nil
|
|
}
|
|
|
|
// createQcow2Image creates a QCOW2 image using qemu-img
|
|
func (b *Builder) createQcow2Image(outputFile string, sizeBytes int64) error {
|
|
// Check if qemu-img is available
|
|
if _, err := exec.LookPath("qemu-img"); err != nil {
|
|
b.logger.Warn("qemu-img not found, falling back to raw image")
|
|
return b.createRawImage(outputFile, sizeBytes)
|
|
}
|
|
|
|
// Create QCOW2 image using qemu-img
|
|
sizeMB := sizeBytes / (1024 * 1024)
|
|
cmd := exec.Command("qemu-img", "create", "-f", "qcow2", outputFile, fmt.Sprintf("%dM", sizeMB))
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("qemu-img failed: %w", err)
|
|
}
|
|
|
|
b.logger.Infof("Created QCOW2 image: %s (%d bytes)", outputFile, sizeBytes)
|
|
return nil
|
|
}
|
|
|
|
// createVmdkImage creates a VMDK image using qemu-img
|
|
func (b *Builder) createVmdkImage(outputFile string, sizeBytes int64) error {
|
|
// Check if qemu-img is available
|
|
if _, err := exec.LookPath("qemu-img"); err != nil {
|
|
b.logger.Warn("qemu-img not found, falling back to raw image")
|
|
return b.createRawImage(outputFile, sizeBytes)
|
|
}
|
|
|
|
// Create VMDK image using qemu-img
|
|
sizeMB := sizeBytes / (1024 * 1024)
|
|
cmd := exec.Command("qemu-img", "create", "-f", "vmdk", outputFile, fmt.Sprintf("%dM", sizeMB))
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("qemu-img failed: %w", err)
|
|
}
|
|
|
|
b.logger.Infof("Created VMDK image: %s (%d bytes)", outputFile, sizeBytes)
|
|
return nil
|
|
}
|
|
|
|
// createVdiImage creates a VDI image using qemu-img
|
|
func (b *Builder) createVdiImage(outputFile string, sizeBytes int64) error {
|
|
// Check if qemu-img is available
|
|
if _, err := exec.LookPath("qemu-img"); err != nil {
|
|
b.logger.Warn("qemu-img not found, falling back to raw image")
|
|
return b.createRawImage(outputFile, sizeBytes)
|
|
}
|
|
|
|
// Create VDI image using qemu-img
|
|
sizeMB := sizeBytes / (1024 * 1024)
|
|
cmd := exec.Command("qemu-img", "create", "-f", "vdi", outputFile, fmt.Sprintf("%dM", sizeMB))
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("qemu-img failed: %w", err)
|
|
}
|
|
|
|
b.logger.Infof("Created VDI image: %s (%d bytes)", outputFile, sizeBytes)
|
|
return nil
|
|
}
|
|
|
|
// createFinalImage creates the final output image
|
|
func (b *Builder) createFinalImage() (string, error) {
|
|
b.logger.Info("Creating final output image")
|
|
|
|
outputDir := filepath.Join(b.workDir, "output")
|
|
|
|
// Get the rootfs path from artifacts
|
|
rootfsPath, exists := b.artifacts["rootfs"]
|
|
if !exists {
|
|
return "", fmt.Errorf("rootfs not found in artifacts")
|
|
}
|
|
|
|
// Create a bootable image using our working approach
|
|
imagePath := filepath.Join(outputDir, fmt.Sprintf("%s.img", b.recipe.Name))
|
|
|
|
// Use our working image creation logic
|
|
if err := b.createBootableImage(rootfsPath, imagePath); err != nil {
|
|
b.logger.Warnf("Failed to create bootable image: %v, falling back to placeholder", err)
|
|
|
|
// Fallback to placeholder if bootable image creation fails
|
|
placeholder := fmt.Sprintf("particle-os image: %s\nbase-image: %s\nstages: %d\nNote: Bootable image creation failed",
|
|
b.recipe.Name, b.recipe.BaseImage, len(b.recipe.Stages))
|
|
|
|
if err := os.WriteFile(imagePath, []byte(placeholder), 0644); err != nil {
|
|
return "", fmt.Errorf("failed to create fallback image: %w", err)
|
|
}
|
|
}
|
|
|
|
return imagePath, nil
|
|
}
|
|
|
|
// createBootableImage creates a bootable disk image from the rootfs
|
|
func (b *Builder) createBootableImage(rootfsPath, outputPath string) error {
|
|
b.logger.Info("Creating bootable disk image")
|
|
|
|
// Create a 5GB raw disk image
|
|
imageSize := int64(5 * 1024 * 1024 * 1024) // 5GB
|
|
|
|
// Create the raw image first
|
|
if err := b.createRawImage(outputPath, imageSize); err != nil {
|
|
return fmt.Errorf("failed to create raw image: %w", err)
|
|
}
|
|
|
|
// Set up loop device
|
|
cmd := exec.Command("sudo", "losetup", "--find", "--show", outputPath)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set up loop device: %w", err)
|
|
}
|
|
loopDevice := strings.TrimSpace(string(output))
|
|
b.loopDevice = loopDevice
|
|
b.logger.Infof("Loop device: %s", loopDevice)
|
|
|
|
// Clean up loop device on exit
|
|
defer func() {
|
|
b.logger.Infof("Cleaning up loop device: %s", loopDevice)
|
|
exec.Command("sudo", "losetup", "-d", loopDevice).Run()
|
|
}()
|
|
|
|
// Create partition table (GPT)
|
|
cmd = exec.Command("sudo", "parted", loopDevice, "mklabel", "gpt")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to create partition table: %w", err)
|
|
}
|
|
|
|
// Create a single partition
|
|
cmd = exec.Command("sudo", "parted", loopDevice, "mkpart", "primary", "ext4", "1MiB", "100%")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to create partition: %w", err)
|
|
}
|
|
|
|
// Get the partition device
|
|
partitionDevice := loopDevice + "p1"
|
|
b.logger.Infof("Partition device: %s", partitionDevice)
|
|
|
|
// Format the partition with ext4
|
|
cmd = exec.Command("sudo", "mkfs.ext4", partitionDevice)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to format partition: %w", err)
|
|
}
|
|
|
|
// Create mount point
|
|
mountPoint := filepath.Join(b.workDir, "mount")
|
|
if err := os.MkdirAll(mountPoint, 0755); err != nil {
|
|
return fmt.Errorf("failed to create mount point: %w", err)
|
|
}
|
|
|
|
// Mount the partition
|
|
cmd = exec.Command("sudo", "mount", partitionDevice, mountPoint)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to mount partition: %w", err)
|
|
}
|
|
|
|
// Clean up mount on exit
|
|
defer func() {
|
|
b.logger.Infof("Unmounting %s", mountPoint)
|
|
exec.Command("sudo", "umount", mountPoint).Run()
|
|
}()
|
|
|
|
// Copy rootfs content
|
|
b.logger.Info("Copying rootfs content")
|
|
cmd = exec.Command("sudo", "cp", "-a", rootfsPath+"/.", mountPoint+"/")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to copy rootfs: %w", err)
|
|
}
|
|
|
|
// Fix permissions after copy
|
|
cmd = exec.Command("sudo", "chown", "-R", "root:root", mountPoint)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
b.logger.Warnf("Could not fix ownership: %v", err)
|
|
}
|
|
|
|
// Create minimal bootable system
|
|
if err := b.setupMinimalBootableSystem(mountPoint); err != nil {
|
|
b.logger.Warnf("Failed to setup minimal bootable system: %v", err)
|
|
}
|
|
|
|
b.logger.Info("Bootable disk image created successfully")
|
|
return nil
|
|
}
|
|
|
|
// setupMinimalBootableSystem sets up the minimal files needed for booting
|
|
func (b *Builder) setupMinimalBootableSystem(mountPoint string) error {
|
|
b.logger.Info("Setting up minimal bootable system")
|
|
|
|
// Create /boot directory if it doesn't exist
|
|
bootDir := filepath.Join(mountPoint, "boot")
|
|
if err := os.MkdirAll(bootDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create boot directory: %w", err)
|
|
}
|
|
|
|
// Create a simple fstab
|
|
fstabContent := `# /etc/fstab for particle-os
|
|
/dev/sda1 / ext4 rw,errors=remount-ro 0 1
|
|
proc /proc proc defaults 0 0
|
|
sysfs /sys sysfs defaults 0 0
|
|
devpts /dev/pts devpts gid=5,mode=620 0 0
|
|
tmpfs /run tmpfs defaults 0 0
|
|
`
|
|
fstabFile := filepath.Join(mountPoint, "etc", "fstab")
|
|
cmd := exec.Command("sudo", "sh", "-c", fmt.Sprintf("echo '%s' > %s", fstabContent, fstabFile))
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to create fstab: %w", err)
|
|
}
|
|
|
|
// Create a simple inittab for sysvinit
|
|
inittabContent := `# /etc/inittab for particle-os
|
|
id:2:initdefault:
|
|
si::sysinit:/etc/init.d/rcS
|
|
1:2345:respawn:/sbin/getty 38400 tty1
|
|
2:23:respawn:/sbin/getty 38400 tty2
|
|
3:23:respawn:/sbin/getty 38400 tty3
|
|
4:23:respawn:/sbin/getty 38400 tty4
|
|
5:23:respawn:/sbin/getty 38400 tty5
|
|
6:23:respawn:/sbin/getty 38400 tty6
|
|
`
|
|
inittabFile := filepath.Join(mountPoint, "etc", "inittab")
|
|
cmd = exec.Command("sudo", "sh", "-c", fmt.Sprintf("echo '%s' > %s", inittabContent, inittabFile))
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to create inittab: %w", err)
|
|
}
|
|
|
|
// Create a simple rcS script
|
|
rcsContent := `#!/bin/sh
|
|
# /etc/init.d/rcS for particle-os
|
|
echo "Starting particle-os..."
|
|
mount -t proc proc /proc
|
|
mount -t sysfs sysfs /sys
|
|
mount -t devpts devpts /dev/pts
|
|
echo "particle-os started successfully"
|
|
`
|
|
rcsFile := filepath.Join(mountPoint, "etc", "init.d", "rcS")
|
|
if err := os.MkdirAll(filepath.Dir(rcsFile), 0755); err != nil {
|
|
return fmt.Errorf("failed to create init.d directory: %w", err)
|
|
}
|
|
cmd = exec.Command("sudo", "sh", "-c", fmt.Sprintf("echo '%s' > %s", rcsContent, rcsFile))
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to create rcS: %w", err)
|
|
}
|
|
// Set executable permissions
|
|
cmd = exec.Command("sudo", "chmod", "755", rcsFile)
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to set rcS permissions: %w", err)
|
|
}
|
|
|
|
// Create a simple kernel command line
|
|
cmdline := "root=/dev/sda1 rw console=ttyS0 init=/bin/sh"
|
|
cmdlineFile := filepath.Join(bootDir, "cmdline.txt")
|
|
cmd = exec.Command("sudo", "sh", "-c", fmt.Sprintf("echo '%s' > %s", cmdline, cmdlineFile))
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to create cmdline.txt: %w", err)
|
|
}
|
|
|
|
// Try to install GRUB bootloader
|
|
if _, err := exec.LookPath("grub-install"); err == nil {
|
|
b.logger.Info("Installing GRUB bootloader")
|
|
// Install GRUB to the loop device
|
|
cmd = exec.Command("sudo", "grub-install", "--target=i386-pc", "--boot-directory="+bootDir, "--modules=part_gpt ext2", b.loopDevice)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
b.logger.Warnf("GRUB installation failed: %v", err)
|
|
} else {
|
|
b.logger.Info("GRUB installed successfully")
|
|
|
|
// Create a basic grub.cfg
|
|
grubCfg := fmt.Sprintf(`# GRUB configuration for particle-os
|
|
set timeout=5
|
|
set default=0
|
|
|
|
menuentry "Particle-OS" {
|
|
set root=(hd0,1)
|
|
linux /boot/vmlinuz-6.12.41+deb13-amd64 root=/dev/sda1 rw console=ttyS0
|
|
initrd /boot/initrd.img-6.12.41+deb13-amd64
|
|
}
|
|
`)
|
|
grubCfgFile := filepath.Join(bootDir, "grub", "grub.cfg")
|
|
cmd = exec.Command("sudo", "sh", "-c", fmt.Sprintf("echo '%s' > %s", grubCfg, grubCfgFile))
|
|
if err := cmd.Run(); err != nil {
|
|
b.logger.Warnf("Failed to create grub.cfg: %v", err)
|
|
}
|
|
}
|
|
} else {
|
|
b.logger.Info("grub-install not available, trying extlinux")
|
|
// Try to install extlinux bootloader as fallback
|
|
if _, err := exec.LookPath("extlinux"); err == nil {
|
|
b.logger.Info("Installing extlinux bootloader")
|
|
cmd = exec.Command("sudo", "extlinux", "--install", bootDir)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
b.logger.Warnf("extlinux installation failed: %v", err)
|
|
} else {
|
|
b.logger.Info("extlinux installed successfully")
|
|
}
|
|
} else {
|
|
b.logger.Info("No bootloader available, image will not be bootable")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractBaseContainer extracts the base container image
|
|
func (b *Builder) extractBaseContainer() error {
|
|
b.logger.Info("Extracting base container image")
|
|
b.logger.Infof("Base image: %s", b.recipe.BaseImage)
|
|
|
|
// Check if container runtime is available
|
|
if !b.container.IsAvailable() {
|
|
return fmt.Errorf("no container runtime (podman/docker) available")
|
|
}
|
|
|
|
// Get container info
|
|
info, err := b.container.GetContainerInfo(b.recipe.BaseImage)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get container info: %w", err)
|
|
}
|
|
|
|
b.logger.Infof("Container info: %s/%s, Size: %d bytes", info.OS, info.Arch, info.Size)
|
|
|
|
// Extract container to rootfs directory
|
|
rootfsDir := filepath.Join(b.workDir, "rootfs")
|
|
if err := b.container.ExtractContainer(b.recipe.BaseImage, rootfsDir); err != nil {
|
|
return fmt.Errorf("failed to extract container: %w", err)
|
|
}
|
|
|
|
b.artifacts["rootfs"] = rootfsDir
|
|
|
|
// Initialize package manager with the extracted rootfs
|
|
b.packages = NewPackageManager(rootfsDir, b.workDir, b.logger.GetLevel())
|
|
|
|
b.logger.Info("Base container extracted successfully")
|
|
return nil
|
|
}
|
|
|
|
// Cleanup removes the working directory
|
|
func (b *Builder) Cleanup() error {
|
|
if b.workDir != "" && strings.HasPrefix(b.workDir, "/tmp/") {
|
|
return os.RemoveAll(b.workDir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// executeKernel runs the kernel installation stage
|
|
func (b *Builder) executeKernel(stage Stage, stageDir string) error {
|
|
b.logger.Info("Executing kernel installation stage")
|
|
|
|
// Extract kernel options
|
|
kernelPackage, _ := stage.Options["kernel_package"].(string)
|
|
initramfs, _ := stage.Options["initramfs"].(bool)
|
|
kernelVersion, _ := stage.Options["kernel_version"].(string)
|
|
kernelArgs, _ := stage.Options["kernel_args"].(string)
|
|
|
|
// Set defaults
|
|
if kernelPackage == "" {
|
|
kernelPackage = "linux-image-amd64"
|
|
}
|
|
if kernelArgs == "" {
|
|
kernelArgs = "root=/dev/sda1 rw console=ttyS0"
|
|
}
|
|
|
|
b.logger.Infof("Kernel configuration: package=%s, initramfs=%t, version=%s, args=%s",
|
|
kernelPackage, initramfs, kernelVersion, kernelArgs)
|
|
|
|
// Get rootfs directory from artifacts
|
|
rootfsDir, exists := b.artifacts["rootfs"]
|
|
if !exists {
|
|
return fmt.Errorf("rootfs not found in artifacts")
|
|
}
|
|
|
|
// Install kernel package using package manager
|
|
if err := b.packages.InstallPackages([]string{kernelPackage}, false, false); err != nil {
|
|
return fmt.Errorf("failed to install kernel package: %w", err)
|
|
}
|
|
b.logger.Info("Kernel package installed successfully")
|
|
|
|
// Create /boot directory if it doesn't exist
|
|
bootDir := filepath.Join(rootfsDir, "boot")
|
|
if err := os.MkdirAll(bootDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create boot directory: %w", err)
|
|
}
|
|
|
|
// Find installed kernel files
|
|
kernelFiles, err := b.findKernelFiles(rootfsDir, kernelPackage)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find kernel files: %w", err)
|
|
}
|
|
|
|
// Copy kernel files to /boot
|
|
for _, kernelFile := range kernelFiles {
|
|
if err := b.copyKernelFileWithSudo(kernelFile, bootDir); err != nil {
|
|
b.logger.Warnf("Failed to copy kernel file %s: %v", kernelFile, err)
|
|
}
|
|
}
|
|
|
|
// Generate initramfs if requested
|
|
if initramfs {
|
|
if err := b.generateInitramfs(rootfsDir, kernelVersion); err != nil {
|
|
b.logger.Warnf("Failed to generate initramfs: %v", err)
|
|
} else {
|
|
b.logger.Info("Initramfs generated successfully")
|
|
}
|
|
}
|
|
|
|
// Create kernel command line file using sudo
|
|
cmdlineFile := filepath.Join(bootDir, "cmdline.txt")
|
|
if err := b.writeFileWithSudo(cmdlineFile, []byte(kernelArgs), 0644); err != nil {
|
|
return fmt.Errorf("failed to create kernel command line: %w", err)
|
|
}
|
|
|
|
// Create kernel configuration using sudo
|
|
kernelConfig := fmt.Sprintf(`# Kernel configuration for particle-os
|
|
KERNEL_PACKAGE=%s
|
|
KERNEL_VERSION=%s
|
|
INITRAMFS=%t
|
|
KERNEL_ARGS="%s"
|
|
`, kernelPackage, kernelVersion, initramfs, kernelArgs)
|
|
|
|
configFile := filepath.Join(bootDir, "kernel.conf")
|
|
if err := b.writeFileWithSudo(configFile, []byte(kernelConfig), 0644); err != nil {
|
|
return fmt.Errorf("failed to create kernel config: %w", err)
|
|
}
|
|
|
|
// Create placeholder
|
|
placeholder := filepath.Join(stageDir, "kernel-completed")
|
|
if err := os.WriteFile(placeholder, []byte("kernel stage completed"), 0644); err != nil {
|
|
return fmt.Errorf("failed to create placeholder: %w", err)
|
|
}
|
|
|
|
b.logger.Info("Kernel installation stage completed successfully")
|
|
return nil
|
|
}
|
|
|
|
// findKernelFiles searches for kernel files in the rootfs
|
|
func (b *Builder) findKernelFiles(rootfsDir, kernelPackage string) ([]string, error) {
|
|
var kernelFiles []string
|
|
|
|
// Common kernel file locations
|
|
kernelPaths := []string{
|
|
filepath.Join(rootfsDir, "boot"),
|
|
filepath.Join(rootfsDir, "lib", "modules"),
|
|
filepath.Join(rootfsDir, "usr", "lib", "modules"),
|
|
}
|
|
|
|
// Search for kernel files
|
|
for _, kernelPath := range kernelPaths {
|
|
if err := filepath.Walk(kernelPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil // Skip errors
|
|
}
|
|
|
|
// Look for kernel image files
|
|
if !info.IsDir() {
|
|
filename := info.Name()
|
|
if strings.HasPrefix(filename, "vmlinuz") ||
|
|
strings.HasPrefix(filename, "bzImage") ||
|
|
strings.HasPrefix(filename, "Image") {
|
|
kernelFiles = append(kernelFiles, path)
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
b.logger.Warnf("Error walking %s: %v", kernelPath, err)
|
|
}
|
|
}
|
|
|
|
if len(kernelFiles) == 0 {
|
|
return nil, fmt.Errorf("no kernel files found in %s", rootfsDir)
|
|
}
|
|
|
|
return kernelFiles, nil
|
|
}
|
|
|
|
// copyKernelFileWithSudo copies a kernel file to the boot directory using sudo
|
|
func (b *Builder) copyKernelFileWithSudo(srcPath, bootDir string) error {
|
|
filename := filepath.Base(srcPath)
|
|
dstPath := filepath.Join(bootDir, filename)
|
|
|
|
// Copy file to boot directory using sudo
|
|
cmd := exec.Command("sudo", "cp", srcPath, dstPath)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to copy kernel file %s to %s: %w", srcPath, dstPath, err)
|
|
}
|
|
|
|
b.logger.Infof("Copied kernel file: %s -> %s", srcPath, dstPath)
|
|
return nil
|
|
}
|
|
|
|
// writeFileWithSudo writes a file to a path using sudo
|
|
func (b *Builder) writeFileWithSudo(path string, data []byte, perm os.FileMode) error {
|
|
cmd := exec.Command("sudo", "tee", path)
|
|
cmd.Stdin = bytes.NewReader(data)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to write file %s: %w", path, err)
|
|
}
|
|
|
|
b.logger.Infof("Wrote file: %s", path)
|
|
return nil
|
|
}
|
|
|
|
// generateInitramfs generates an initramfs for the kernel
|
|
func (b *Builder) generateInitramfs(rootfsDir, kernelVersion string) error {
|
|
// Check if update-initramfs is available
|
|
if _, err := exec.LookPath("update-initramfs"); err != nil {
|
|
b.logger.Warn("update-initramfs not available, skipping initramfs generation")
|
|
return nil
|
|
}
|
|
|
|
// Create initramfs using update-initramfs
|
|
cmd := exec.Command("sudo", "chroot", rootfsDir, "update-initramfs", "-u", "-k", kernelVersion)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to generate initramfs: %w", err)
|
|
}
|
|
|
|
b.logger.Info("Initramfs generated successfully")
|
|
return nil
|
|
}
|