first commit

This commit is contained in:
robojerk 2025-09-05 07:10:12 -07:00
commit 7584207f76
72 changed files with 12801 additions and 0 deletions

5
bib/aptcache/apt.conf Normal file
View file

@ -0,0 +1,5 @@
APT::Architecture "amd64";
APT::Get::Assume-Yes "true";
APT::Get::AllowUnauthenticated "true";
APT::Cache::Generate "true";
Dir::Etc::SourceList "aptcache/sources.list";

View file

@ -0,0 +1,4 @@
deb [arch=amd64] http://deb.debian.org/debian trixie main
deb [arch=amd64] http://security.debian.org/debian-security trixie-security main
deb [arch=amd64] http://deb.debian.org/debian trixie-updates main
deb [arch=amd64] https://git.raines.xyz/api/packages/particle-os/debian trixie main

View file

@ -0,0 +1,302 @@
package main
import (
"fmt"
"os"
"debian-bootc-image-builder/internal/apt"
"debian-bootc-image-builder/internal/container"
"debian-bootc-image-builder/internal/config"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
// GibiByte represents 1 GiB in bytes
const GibiByte = 1024 * 1024 * 1024
// ManifestConfig contains configuration for manifest generation
type ManifestConfig struct {
Architecture string
Config *config.Config
ImageTypes []string
Imgref string
BuildImgref string
RootfsMinsize uint64
DistroDefPaths []string
SourceInfo interface{} // Placeholder for osinfo.Info
BuildSourceInfo interface{} // Placeholder for osinfo.Info
RootFSType string
UseLibrepo bool
}
// makeManifest creates an OSBuild manifest for Debian images
func makeManifest(c *ManifestConfig, solver *apt.Solver, cacheRoot string) (map[string]interface{}, map[string][]apt.RepoConfig, error) {
logrus.Debugf("Creating manifest for architecture: %s", c.Architecture)
// Create a simple OSBuild manifest structure with only APT stages
manifest := map[string]interface{}{
"version": "2",
"pipelines": []map[string]interface{}{
{
"name": "build",
"runner": "org.osbuild.linux",
"stages": []map[string]interface{}{
{
"type": "org.osbuild.apt.depsolve",
"options": map[string]interface{}{
"packages": []string{"base-files", "systemd", "linux-image-amd64"},
},
},
{
"type": "org.osbuild.apt",
"options": map[string]interface{}{
"packages": []string{"base-files", "systemd", "linux-image-amd64"},
},
},
},
},
},
}
// Depsolve packages for each package set
depsolvedSets := make(map[string]apt.DepsolveResult)
depsolvedRepos := make(map[string][]apt.RepoConfig)
// Load package definitions for the image type
packageSet, err := c.loadPackageDefinitions()
if err != nil {
return nil, nil, fmt.Errorf("cannot load package definitions: %w", err)
}
res, err := solver.Depsolve(packageSet, 0)
if err != nil {
return nil, nil, fmt.Errorf("cannot depsolve packages: %w", err)
}
depsolvedSets["build"] = *res
depsolvedRepos["build"] = res.Repos
// Add resolved packages to manifest
if pipelines, ok := manifest["pipelines"].([]map[string]interface{}); ok {
for _, pipeline := range pipelines {
if pipeline["name"] == "build" {
if stages, ok := pipeline["stages"].([]map[string]interface{}); ok {
// Update depsolve stage with resolved packages
if len(stages) > 0 && stages[0]["type"] == "org.osbuild.apt.depsolve" {
if options, ok := stages[0]["options"].(map[string]interface{}); ok {
packages := make([]string, len(res.Packages))
for i, pkg := range res.Packages {
packages[i] = pkg.Name
}
options["packages"] = packages
// APT depsolve stage doesn't accept repositories option
// Repository configuration is handled by the APT stage
}
}
// Update apt stage with resolved packages
if len(stages) > 1 && stages[1]["type"] == "org.osbuild.apt" {
if options, ok := stages[1]["options"].(map[string]interface{}); ok {
packages := make([]string, len(res.Packages))
for i, pkg := range res.Packages {
packages[i] = pkg.Name
}
options["packages"] = packages
}
}
}
break
}
}
}
return manifest, depsolvedRepos, nil
}
// manifestFromCobra creates a manifest from command line arguments
func manifestFromCobra(cmd *cobra.Command, args []string, pbar interface{}) (map[string]interface{}, interface{}, error) {
if len(args) != 1 {
return nil, nil, fmt.Errorf("expected exactly one argument (container image reference)")
}
imgref := args[0]
logrus.Debugf("Processing container image: %s", imgref)
// Get command line flags
userConfigFile, _ := cmd.Flags().GetString("config")
imgTypes, _ := cmd.Flags().GetStringArray("type")
aptCacheRoot, _ := cmd.Flags().GetString("aptcache")
targetArch, _ := cmd.Flags().GetString("target-arch")
rootFs, _ := cmd.Flags().GetString("rootfs")
buildImgref, _ := cmd.Flags().GetString("build-container")
distroDefPaths, _ := cmd.Flags().GetStringArray("distro-def-path")
useLibrepo, _ := cmd.Flags().GetBool("librepo")
// Load configuration (optional - use defaults if not found)
cfg, err := config.LoadConfig(userConfigFile)
if err != nil {
logrus.Warnf("Could not load configuration: %v, using defaults", err)
cfg = nil // Use defaults
}
// Set default values
if aptCacheRoot == "" {
aptCacheRoot = "/aptcache"
}
if targetArch == "" {
targetArch = "amd64"
}
if len(imgTypes) == 0 {
imgTypes = []string{"qcow2"}
}
if len(distroDefPaths) == 0 {
distroDefPaths = []string{"./data/defs"}
}
// Create container instance
container, err := container.New(imgref)
if err != nil {
return nil, nil, fmt.Errorf("cannot create container: %w", err)
}
defer func() {
if err := container.Stop(); err != nil {
logrus.Warnf("error stopping container: %v", err)
}
}()
// Get container size (simplified)
cntSize := uint64(10 * GibiByte) // Default size
// Get root filesystem type
var rootfsType string
if rootFs != "" {
rootfsType = rootFs
} else {
rootfsType, err = container.DefaultRootfsType()
if err != nil {
return nil, nil, fmt.Errorf("cannot get rootfs type for container: %w", err)
}
if rootfsType == "" {
rootfsType = "ext4"
}
}
// Initialize APT in container
if err := container.InitAPT(); err != nil {
return nil, nil, fmt.Errorf("cannot initialize APT: %w", err)
}
// Create solver
solver, err := container.NewContainerSolver(aptCacheRoot, targetArch, nil)
if err != nil {
return nil, nil, fmt.Errorf("cannot create solver: %w", err)
}
// Create manifest configuration
var rootfsMinsize uint64
if cfg != nil {
rootfsMinsize = cntSize * uint64(cfg.GetContainerSizeMultiplier())
} else {
// Default multiplier when no config is available
rootfsMinsize = cntSize * 2 // Default 2x multiplier
}
manifestConfig := &ManifestConfig{
Architecture: targetArch,
Config: cfg,
ImageTypes: imgTypes,
Imgref: imgref,
BuildImgref: buildImgref,
RootfsMinsize: rootfsMinsize,
DistroDefPaths: distroDefPaths,
SourceInfo: nil, // Placeholder
BuildSourceInfo: nil, // Placeholder
RootFSType: rootfsType,
UseLibrepo: useLibrepo,
}
// Generate manifest
manifest, repos, err := makeManifest(manifestConfig, solver, aptCacheRoot)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate manifest: %w", err)
}
return manifest, repos, nil
}
// getContainerSize gets the size of a container (simplified implementation)
func getContainerSize(imgref string) (uint64, error) {
// This is a simplified implementation
// In a real implementation, this would:
// 1. Inspect the container image
// 2. Calculate the actual size
// 3. Return the size in bytes
logrus.Debugf("Getting container size for: %s", imgref)
return 10 * GibiByte, nil // Default 10GB
}
// getDistroAndRunner returns the distribution and runner for Debian
func getDistroAndRunner(osRelease interface{}) (string, interface{}, error) {
// This is a simplified implementation for Debian
// In a real implementation, this would parse os-release information
return "debian", &DebianRunner{}, nil
}
// DebianRunner represents a Debian-specific runner
type DebianRunner struct {
Version string
}
// String returns the string representation of the runner
func (r *DebianRunner) String() string {
return "debian"
}
// PackageDefinition represents a package definition structure
type PackageDefinition struct {
Packages []string `yaml:"packages"`
}
// loadPackageDefinitions loads package definitions for the specified image types
func (c *ManifestConfig) loadPackageDefinitions() ([]string, error) {
// For now, use the first image type to determine packages
if len(c.ImageTypes) == 0 {
return []string{"base-files", "systemd", "linux-image-amd64"}, nil
}
imageType := c.ImageTypes[0]
// Try to load from package definition files
for _, defPath := range c.DistroDefPaths {
defFile := fmt.Sprintf("%s/debian-trixie.yaml", defPath)
if packages, err := c.loadPackagesFromFile(defFile, imageType); err == nil {
return packages, nil
}
}
// Fallback to default packages
logrus.Warnf("Could not load package definitions for %s, using defaults", imageType)
return []string{"base-files", "systemd", "linux-image-amd64"}, nil
}
// loadPackagesFromFile loads packages from a YAML definition file
func (c *ManifestConfig) loadPackagesFromFile(filename, imageType string) ([]string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("cannot read package definition file %s: %w", filename, err)
}
var defs map[string]PackageDefinition
if err := yaml.Unmarshal(data, &defs); err != nil {
return nil, fmt.Errorf("cannot parse package definition file %s: %w", filename, err)
}
if pkgDef, exists := defs[imageType]; exists {
return pkgDef.Packages, nil
}
return nil, fmt.Errorf("no package definition found for image type %s", imageType)
}

View file

@ -0,0 +1,577 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"debian-bootc-image-builder/internal/apt"
"debian-bootc-image-builder/internal/config"
)
func TestManifestConfig(t *testing.T) {
// Test creating a new ManifestConfig
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{"qcow2"},
TargetArch: "amd64",
RootfsType: "ext4",
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
assert.Equal(t, "test-image", config.ImageName)
assert.Equal(t, []string{"qcow2"}, config.ImageTypes)
assert.Equal(t, "amd64", config.TargetArch)
assert.Equal(t, "ext4", config.RootfsType)
assert.Equal(t, []string{"./data/defs"}, config.DistroDefPath)
assert.Equal(t, "debian:trixie", config.BuildContainer)
}
func TestManifestConfigLoadPackageDefinitions(t *testing.T) {
// Create a temporary directory with test package definitions
tmpDir := t.TempDir()
defsDir := filepath.Join(tmpDir, "defs")
err := os.MkdirAll(defsDir, 0755)
require.NoError(t, err)
// Create a test package definition file
defFile := filepath.Join(defsDir, "debian-trixie.yaml")
defContent := `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
ami:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- cloud-guest-utils
- bootc
- apt-ostree
vmdk:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- open-vm-tools
- bootc
- apt-ostree
debian-installer:
packages:
- base-files
- systemd
- linux-image-amd64
- debian-installer
- bootc
- apt-ostree
calamares:
packages:
- base-files
- systemd
- linux-image-amd64
- calamares
- bootc
- apt-ostree
`
err = os.WriteFile(defFile, []byte(defContent), 0644)
require.NoError(t, err)
// Test loading package definitions
config := &ManifestConfig{
ImageTypes: []string{"qcow2"},
DistroDefPath: []string{defsDir},
}
packages, err := config.loadPackageDefinitions()
assert.NoError(t, err)
assert.NotEmpty(t, packages)
assert.Contains(t, packages, "base-files")
assert.Contains(t, packages, "systemd")
assert.Contains(t, packages, "linux-image-amd64")
assert.Contains(t, packages, "grub-common")
assert.Contains(t, packages, "bootc")
assert.Contains(t, packages, "apt-ostree")
}
func TestManifestConfigLoadPackageDefinitionsAMI(t *testing.T) {
// Create a temporary directory with test package definitions
tmpDir := t.TempDir()
defsDir := filepath.Join(tmpDir, "defs")
err := os.MkdirAll(defsDir, 0755)
require.NoError(t, err)
// Create a test package definition file
defFile := filepath.Join(defsDir, "debian-trixie.yaml")
defContent := `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
ami:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- cloud-guest-utils
- bootc
- apt-ostree
`
err = os.WriteFile(defFile, []byte(defContent), 0644)
require.NoError(t, err)
// Test loading AMI package definitions
config := &ManifestConfig{
ImageTypes: []string{"ami"},
DistroDefPath: []string{defsDir},
}
packages, err := config.loadPackageDefinitions()
assert.NoError(t, err)
assert.NotEmpty(t, packages)
assert.Contains(t, packages, "base-files")
assert.Contains(t, packages, "systemd")
assert.Contains(t, packages, "linux-image-amd64")
assert.Contains(t, packages, "grub-common")
assert.Contains(t, packages, "cloud-guest-utils")
assert.Contains(t, packages, "bootc")
assert.Contains(t, packages, "apt-ostree")
}
func TestManifestConfigLoadPackageDefinitionsVMDK(t *testing.T) {
// Create a temporary directory with test package definitions
tmpDir := t.TempDir()
defsDir := filepath.Join(tmpDir, "defs")
err := os.MkdirAll(defsDir, 0755)
require.NoError(t, err)
// Create a test package definition file
defFile := filepath.Join(defsDir, "debian-trixie.yaml")
defContent := `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
vmdk:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- open-vm-tools
- bootc
- apt-ostree
`
err = os.WriteFile(defFile, []byte(defContent), 0644)
require.NoError(t, err)
// Test loading VMDK package definitions
config := &ManifestConfig{
ImageTypes: []string{"vmdk"},
DistroDefPath: []string{defsDir},
}
packages, err := config.loadPackageDefinitions()
assert.NoError(t, err)
assert.NotEmpty(t, packages)
assert.Contains(t, packages, "base-files")
assert.Contains(t, packages, "systemd")
assert.Contains(t, packages, "linux-image-amd64")
assert.Contains(t, packages, "grub-common")
assert.Contains(t, packages, "open-vm-tools")
assert.Contains(t, packages, "bootc")
assert.Contains(t, packages, "apt-ostree")
}
func TestManifestConfigLoadPackageDefinitionsDebianInstaller(t *testing.T) {
// Create a temporary directory with test package definitions
tmpDir := t.TempDir()
defsDir := filepath.Join(tmpDir, "defs")
err := os.MkdirAll(defsDir, 0755)
require.NoError(t, err)
// Create a test package definition file
defFile := filepath.Join(defsDir, "debian-trixie.yaml")
defContent := `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
debian-installer:
packages:
- base-files
- systemd
- linux-image-amd64
- debian-installer
- bootc
- apt-ostree
`
err = os.WriteFile(defFile, []byte(defContent), 0644)
require.NoError(t, err)
// Test loading debian-installer package definitions
config := &ManifestConfig{
ImageTypes: []string{"debian-installer"},
DistroDefPath: []string{defsDir},
}
packages, err := config.loadPackageDefinitions()
assert.NoError(t, err)
assert.NotEmpty(t, packages)
assert.Contains(t, packages, "base-files")
assert.Contains(t, packages, "systemd")
assert.Contains(t, packages, "linux-image-amd64")
assert.Contains(t, packages, "debian-installer")
assert.Contains(t, packages, "bootc")
assert.Contains(t, packages, "apt-ostree")
}
func TestManifestConfigLoadPackageDefinitionsCalamares(t *testing.T) {
// Create a temporary directory with test package definitions
tmpDir := t.TempDir()
defsDir := filepath.Join(tmpDir, "defs")
err := os.MkdirAll(defsDir, 0755)
require.NoError(t, err)
// Create a test package definition file
defFile := filepath.Join(defsDir, "debian-trixie.yaml")
defContent := `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
calamares:
packages:
- base-files
- systemd
- linux-image-amd64
- calamares
- bootc
- apt-ostree
`
err = os.WriteFile(defFile, []byte(defContent), 0644)
require.NoError(t, err)
// Test loading calamares package definitions
config := &ManifestConfig{
ImageTypes: []string{"calamares"},
DistroDefPath: []string{defsDir},
}
packages, err := config.loadPackageDefinitions()
assert.NoError(t, err)
assert.NotEmpty(t, packages)
assert.Contains(t, packages, "base-files")
assert.Contains(t, packages, "systemd")
assert.Contains(t, packages, "linux-image-amd64")
assert.Contains(t, packages, "calamares")
assert.Contains(t, packages, "bootc")
assert.Contains(t, packages, "apt-ostree")
}
func TestManifestConfigLoadPackageDefinitionsNotFound(t *testing.T) {
// Test loading package definitions from non-existent directory
config := &ManifestConfig{
ImageTypes: []string{"qcow2"},
DistroDefPath: []string{"/non/existent/path"},
}
packages, err := config.loadPackageDefinitions()
assert.Error(t, err)
assert.Empty(t, packages)
}
func TestManifestConfigLoadPackageDefinitionsInvalidYAML(t *testing.T) {
// Create a temporary directory with invalid YAML
tmpDir := t.TempDir()
defsDir := filepath.Join(tmpDir, "defs")
err := os.MkdirAll(defsDir, 0755)
require.NoError(t, err)
// Create an invalid YAML file
defFile := filepath.Join(defsDir, "debian-trixie.yaml")
invalidContent := `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
ami:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- cloud-guest-utils
- bootc
- apt-ostree
`
err = os.WriteFile(defFile, []byte(invalidContent), 0644)
require.NoError(t, err)
// Test loading package definitions
config := &ManifestConfig{
ImageTypes: []string{"qcow2"},
DistroDefPath: []string{defsDir},
}
packages, err := config.loadPackageDefinitions()
assert.NoError(t, err)
assert.NotEmpty(t, packages)
}
func TestMakeManifest(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Create a test configuration
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{"qcow2"},
TargetArch: "amd64",
RootfsType: "ext4",
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
// Create a test solver
solver := apt.NewSolver()
// Test manifest generation
manifest, repos, err := makeManifest(config, solver, tmpDir)
assert.NoError(t, err)
assert.NotNil(t, manifest)
assert.NotNil(t, repos)
// Test manifest structure
assert.Contains(t, manifest, "version")
assert.Contains(t, manifest, "pipelines")
// Test that version is "2"
assert.Equal(t, "2", manifest["version"])
// Test that pipelines is a slice
pipelines, ok := manifest["pipelines"].([]map[string]interface{})
assert.True(t, ok)
assert.Len(t, pipelines, 2)
// Test build pipeline
buildPipeline := pipelines[0]
assert.Equal(t, "build", buildPipeline["name"])
assert.Equal(t, "org.osbuild.linux", buildPipeline["runner"])
// Test image pipeline
imagePipeline := pipelines[1]
assert.Equal(t, "image", imagePipeline["name"])
assert.Equal(t, "org.osbuild.linux", imagePipeline["runner"])
}
func TestMakeManifestWithDifferentImageTypes(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Test with different image types
imageTypes := []string{"qcow2", "ami", "vmdk", "debian-installer", "calamares"}
for _, imageType := range imageTypes {
t.Run(imageType, func(t *testing.T) {
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{imageType},
TargetArch: "amd64",
RootfsType: "ext4",
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
solver := apt.NewSolver()
manifest, repos, err := makeManifest(config, solver, tmpDir)
assert.NoError(t, err)
assert.NotNil(t, manifest)
assert.NotNil(t, repos)
// Test manifest structure
assert.Contains(t, manifest, "version")
assert.Contains(t, manifest, "pipelines")
assert.Equal(t, "2", manifest["version"])
// Test that pipelines is a slice
pipelines, ok := manifest["pipelines"].([]map[string]interface{})
assert.True(t, ok)
assert.Len(t, pipelines, 2)
})
}
}
func TestMakeManifestWithDifferentArchitectures(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Test with different architectures
architectures := []string{"amd64", "arm64", "ppc64le", "s390x"}
for _, arch := range architectures {
t.Run(arch, func(t *testing.T) {
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{"qcow2"},
TargetArch: arch,
RootfsType: "ext4",
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
solver := apt.NewSolver()
manifest, repos, err := makeManifest(config, solver, tmpDir)
assert.NoError(t, err)
assert.NotNil(t, manifest)
assert.NotNil(t, repos)
// Test manifest structure
assert.Contains(t, manifest, "version")
assert.Contains(t, manifest, "pipelines")
assert.Equal(t, "2", manifest["version"])
})
}
}
func TestMakeManifestWithDifferentRootfsTypes(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Test with different rootfs types
rootfsTypes := []string{"ext4", "xfs", "btrfs"}
for _, rootfsType := range rootfsTypes {
t.Run(rootfsType, func(t *testing.T) {
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{"qcow2"},
TargetArch: "amd64",
RootfsType: rootfsType,
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
solver := apt.NewSolver()
manifest, repos, err := makeManifest(config, solver, tmpDir)
assert.NoError(t, err)
assert.NotNil(t, manifest)
assert.NotNil(t, repos)
// Test manifest structure
assert.Contains(t, manifest, "version")
assert.Contains(t, manifest, "pipelines")
assert.Equal(t, "2", manifest["version"])
})
}
}
func TestMakeManifestErrorHandling(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Test with invalid configuration
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{"qcow2"},
TargetArch: "amd64",
RootfsType: "ext4",
DistroDefPath: []string{"/non/existent/path"},
BuildContainer: "debian:trixie",
}
solver := apt.NewSolver()
// Test manifest generation with invalid distro def path
manifest, repos, err := makeManifest(config, solver, tmpDir)
assert.Error(t, err)
assert.Nil(t, manifest)
assert.Nil(t, repos)
}
func TestMakeManifestWithEmptyImageTypes(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Test with empty image types
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{},
TargetArch: "amd64",
RootfsType: "ext4",
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
solver := apt.NewSolver()
// Test manifest generation with empty image types
manifest, repos, err := makeManifest(config, solver, tmpDir)
assert.Error(t, err)
assert.Nil(t, manifest)
assert.Nil(t, repos)
}
func TestMakeManifestWithNilSolver(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Test with nil solver
config := &ManifestConfig{
ImageName: "test-image",
ImageTypes: []string{"qcow2"},
TargetArch: "amd64",
RootfsType: "ext4",
DistroDefPath: []string{"./data/defs"},
BuildContainer: "debian:trixie",
}
// Test manifest generation with nil solver
manifest, repos, err := makeManifest(config, nil, tmpDir)
assert.Error(t, err)
assert.Nil(t, manifest)
assert.Nil(t, repos)
}

View file

@ -0,0 +1,330 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"debian-bootc-image-builder/internal/config"
"debian-bootc-image-builder/internal/ux"
)
func main() {
// Global flags
var verbose, debug, runDiagnostics bool
rootCmd := &cobra.Command{
Use: "debian-bootc-image-builder",
Short: "Create a bootable image from a Debian bootc container",
Long: `Create a bootable image from a Debian bootc container.
This tool builds bootable disk images from Debian bootc containers using APT
for package management and OSBuild for image generation.
Examples:
# Build a qcow2 image
debian-bootc-image-builder build git.raines.xyz/particle-os/debian-bootc:latest
# Build multiple image types
debian-bootc-image-builder build --type qcow2 --type ami git.raines.xyz/particle-os/debian-bootc:latest
# Run system diagnostics
debian-bootc-image-builder diagnose
# Show detailed help
debian-bootc-image-builder build --help`,
}
// Add global flags
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug output (includes verbose)")
rootCmd.PersistentFlags().BoolVar(&runDiagnostics, "diagnose", false, "Run system diagnostics before operations")
// Load configuration
cfg, err := config.LoadConfig("")
if err != nil {
if verbose || debug {
log.Printf("Warning: Could not load configuration: %v", err)
log.Println("Using default settings...")
}
}
buildCmd := &cobra.Command{
Use: "build IMAGE_NAME",
Short: "Build a bootable image from a Debian bootc container",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Enable debug mode if requested
if debug {
verbose = true
}
// Run diagnostics if requested
if runDiagnostics {
ux.PrintInfo(os.Stdout, "Running system diagnostics...")
troubleshooter := ux.NewTroubleshootingGuide(verbose)
results := troubleshooter.RunDiagnostics()
troubleshooter.PrintDiagnostics(results)
// Check for critical issues
criticalIssues := 0
for _, result := range results {
if result.Critical && strings.Contains(result.Status, "❌") {
criticalIssues++
}
}
if criticalIssues > 0 {
return ux.NewUserError(
ux.ErrorTypeValidation,
fmt.Sprintf("Found %d critical system issues", criticalIssues),
"System diagnostics failed",
"Resolve the critical issues above before proceeding",
nil,
)
}
}
// Validate inputs
validator := ux.NewValidator(verbose)
// Validate image reference
imgref := args[0]
if result := validator.ValidateImageReference(imgref); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
// Get and validate command flags
imageTypes, _ := cmd.Flags().GetStringArray("type")
if result := validator.ValidateImageTypes(imageTypes); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
targetArch, _ := cmd.Flags().GetString("target-arch")
if result := validator.ValidateArchitecture(targetArch); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
rootfsType, _ := cmd.Flags().GetString("rootfs")
if result := validator.ValidateRootfsType(rootfsType); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
aptcache, _ := cmd.Flags().GetString("aptcache")
if result := validator.ValidateDirectory(aptcache, "APT cache"); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
configPath, _ := cmd.Flags().GetString("config")
if result := validator.ValidateConfigFile(configPath); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
distroDefPaths, _ := cmd.Flags().GetStringArray("distro-def-path")
for _, path := range distroDefPaths {
if result := validator.ValidateDistroDefPath(path); !result.Valid {
validator.PrintValidationResult(result)
return ux.ValidationError(result.Message, nil)
}
}
// Create progress reporter
progress := ux.NewProgressReporter(os.Stdout, verbose)
progress.AddStep("validation", "Input validation")
progress.AddStep("config", "Configuration loading")
progress.AddStep("manifest", "Manifest generation")
progress.AddStep("output", "Output file creation")
// Start validation step
progress.StartStep(0)
progress.CompleteStep(0)
// Configuration step
progress.StartStep(1)
if verbose {
ux.PrintInfo(os.Stdout, fmt.Sprintf("Building image from: %s", imgref))
if cfg != nil {
ux.PrintInfo(os.Stdout, fmt.Sprintf("Using configuration from: %s", cfg.ActiveRegistry))
registry, err := cfg.GetActiveRegistry()
if err == nil {
ux.PrintInfo(os.Stdout, fmt.Sprintf("Registry: %s/%s", registry.BaseURL, registry.Namespace))
}
}
}
progress.CompleteStep(1)
// Manifest generation step
progress.StartStep(2)
manifest, _, err := manifestFromCobra(cmd, args, nil)
if err != nil {
progress.FailStep(2, err)
return ux.ManifestError("Cannot generate manifest", err)
}
progress.CompleteStep(2)
// Output step
progress.StartStep(3)
outputDir := "./output"
if err := os.MkdirAll(outputDir, 0755); err != nil {
progress.FailStep(3, err)
return ux.FilesystemError("Cannot create output directory", err)
}
manifestFile := fmt.Sprintf("%s/manifest.json", outputDir)
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
progress.FailStep(3, err)
return ux.ManifestError("Cannot marshal manifest", err)
}
if err := os.WriteFile(manifestFile, manifestData, 0644); err != nil {
progress.FailStep(3, err)
return ux.FilesystemError("Cannot write manifest file", err)
}
progress.CompleteStep(3)
// OSBuild execution step
progress.AddStep("osbuild", "OSBuild image generation")
progress.StartStep(4)
if verbose {
ux.PrintInfo(os.Stdout, "Running OSBuild to generate image...")
}
// Run OSBuild with the generated manifest
if err := runOSBuild(manifestFile, outputDir, verbose); err != nil {
progress.FailStep(4, err)
return ux.BuildError("OSBuild execution failed", err)
}
progress.CompleteStep(4)
// Print summary
progress.PrintSummary()
ux.PrintSuccess(os.Stdout, fmt.Sprintf("Image built successfully in: %s", outputDir))
if verbose {
ux.PrintInfo(os.Stdout, "Built using OSBuild with APT integration for Debian package management.")
ux.PrintInfo(os.Stdout, "Check the output directory for generated image files.")
}
return nil
},
}
diagnoseCmd := &cobra.Command{
Use: "diagnose",
Short: "Run system diagnostics to check prerequisites",
Long: `Run comprehensive system diagnostics to check if your system
is ready for building Debian bootc images.
This command checks:
- Required tools (apt-cache, podman, qemu-img, file)
- System resources (disk space, memory)
- File permissions
- Network connectivity
- Container runtime functionality`,
RunE: func(cmd *cobra.Command, args []string) error {
// Enable debug mode if requested
if debug {
verbose = true
}
ux.PrintInfo(os.Stdout, "Running comprehensive system diagnostics...")
troubleshooter := ux.NewTroubleshootingGuide(verbose)
results := troubleshooter.RunDiagnostics()
troubleshooter.PrintDiagnostics(results)
// Check for critical issues
criticalIssues := 0
warnings := 0
for _, result := range results {
if result.Critical && strings.Contains(result.Status, "❌") {
criticalIssues++
} else if strings.Contains(result.Status, "⚠️") {
warnings++
}
}
fmt.Println()
if criticalIssues > 0 {
ux.PrintError(os.Stdout, fmt.Sprintf("Found %d critical issues that must be resolved", criticalIssues))
ux.PrintInfo(os.Stdout, "Please resolve the critical issues above before building images.")
return ux.NewUserError(
ux.ErrorTypeValidation,
fmt.Sprintf("System has %d critical issues", criticalIssues),
"System diagnostics failed",
"Resolve the critical issues above before proceeding",
nil,
)
} else if warnings > 0 {
ux.PrintWarning(os.Stdout, fmt.Sprintf("Found %d warnings (non-critical)", warnings))
ux.PrintInfo(os.Stdout, "Your system is ready, but consider addressing the warnings above.")
} else {
ux.PrintSuccess(os.Stdout, "All diagnostics passed - your system is ready for building images!")
}
return nil
},
}
versionCmd := &cobra.Command{
Use: "version",
Short: "Show version information",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("debian-bootc-image-builder v0.1.0")
fmt.Println("Debian bootc image builder with APT integration")
fmt.Println("Built with comprehensive error handling and UX improvements")
return nil
},
}
// Add flags to build command
buildCmd.Flags().String("config", "", "Path to configuration file")
buildCmd.Flags().StringArray("type", []string{"qcow2"}, "Image types to build")
buildCmd.Flags().String("aptcache", "/aptcache", "APT cache directory")
buildCmd.Flags().String("target-arch", "amd64", "Target architecture")
buildCmd.Flags().String("rootfs", "", "Root filesystem type")
buildCmd.Flags().String("build-container", "", "Use a custom container for the image build")
buildCmd.Flags().StringArray("distro-def-path", []string{"./data/defs"}, "Path to distribution definition files")
buildCmd.Flags().Bool("librepo", false, "Use librepo for package downloads")
rootCmd.AddCommand(buildCmd)
rootCmd.AddCommand(diagnoseCmd)
rootCmd.AddCommand(versionCmd)
if err := rootCmd.Execute(); err != nil {
// Use our enhanced error formatting
fmt.Print(ux.FormatError(err))
os.Exit(1)
}
}
// runOSBuild executes OSBuild with the given manifest file
func runOSBuild(manifestFile, outputDir string, verbose bool) error {
// Use the system osbuild command
cmd := exec.Command("osbuild",
"--output-directory", outputDir,
manifestFile)
if verbose {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("osbuild execution failed: %w", err)
}
return nil
}

View file

@ -0,0 +1,466 @@
# Debian 13 (Trixie) package definitions for bootc-image-builder
# This file defines the packages needed for different image types
qcow2:
packages:
# Essential system packages
- base-files
- systemd
- systemd-sysv
- init-system-helpers
- debianutils
- util-linux
- coreutils
- findutils
- grep
- gzip
- tar
- bzip2
- xz-utils
- zstd
# Kernel and boot
- linux-image-amd64
- linux-headers-amd64
- grub-common
- grub-pc
- grub-pc-bin
- grub2-common
# Network and connectivity
- netbase
- ifupdown
- iproute2
- iputils-ping
- net-tools
- openssh-server
- openssh-client
- curl
- wget
- ca-certificates
# Package management
- apt
- apt-utils
- dpkg
- debconf
- debian-archive-keyring
# Bootc and ostree support
- bootc
- apt-ostree
- deb-bootupd
# Security and authentication
- sudo
- passwd
- login
- libpam-modules
- libpam-modules-bin
- libpam-runtime
- libpam-systemd
# Hardware support
- udev
- pciutils
- usbutils
- hdparm
- smartmontools
# Filesystem support
- e2fsprogs
- xfsprogs
- btrfs-progs
- dosfstools
- ntfs-3g
# System utilities
- procps
- psmisc
- lsof
- strace
- less
- nano
- vim-tiny
# Logging and monitoring
- rsyslog
- logrotate
- cron
- anacron
# Time and locale
- tzdata
- locales
- keyboard-configuration
debian-installer:
packages:
# Base installer packages
- debian-installer
- debian-installer-netboot-amd64
- debian-installer-utils
- debian-installer-launcher
# Kernel for installer
- linux-image-amd64
- linux-headers-amd64
# Network support for installer
- netbase
- ifupdown
- iproute2
- dhcp-client
- isc-dhcp-client
# Package management for installer
- apt
- apt-utils
- dpkg
- debconf
# Boot support
- grub-common
- grub-pc
- grub-pc-bin
- grub2-common
# Essential system packages
- base-files
- systemd
- systemd-sysv
- util-linux
- coreutils
- findutils
- grep
- gzip
- tar
- bzip2
- xz-utils
# Hardware support
- udev
- pciutils
- usbutils
# Filesystem support
- e2fsprogs
- xfsprogs
- btrfs-progs
- dosfstools
# Bootc support
- bootc
- apt-ostree
calamares:
packages:
# Calamares installer
- calamares
- calamares-settings-debian
- calamares-settings-debian-desktop
# Desktop environment (minimal)
- xorg
- xserver-xorg-core
- xserver-xorg-video-all
- xinit
- xterm
# Display manager
- lightdm
- lightdm-gtk-greeter
# Essential desktop packages
- desktop-base
- desktop-file-utils
- xdg-utils
- xdg-user-dirs
# Kernel and boot
- linux-image-amd64
- linux-headers-amd64
- grub-common
- grub-pc
- grub-pc-bin
- grub2-common
# Network support
- netbase
- ifupdown
- iproute2
- network-manager
- network-manager-gnome
- openssh-server
- openssh-client
- curl
- wget
- ca-certificates
# Package management
- apt
- apt-utils
- dpkg
- debconf
- debian-archive-keyring
# Bootc and ostree support
- bootc
- apt-ostree
- deb-bootupd
# Essential system packages
- base-files
- systemd
- systemd-sysv
- util-linux
- coreutils
- findutils
- grep
- gzip
- tar
- bzip2
- xz-utils
# Security and authentication
- sudo
- passwd
- login
- libpam-modules
- libpam-modules-bin
- libpam-runtime
- libpam-systemd
# Hardware support
- udev
- pciutils
- usbutils
- hdparm
- smartmontools
# Filesystem support
- e2fsprogs
- xfsprogs
- btrfs-progs
- dosfstools
- ntfs-3g
# System utilities
- procps
- psmisc
- lsof
- strace
- less
- nano
- vim-tiny
# Logging and monitoring
- rsyslog
- logrotate
- cron
- anacron
# Time and locale
- tzdata
- locales
- keyboard-configuration
# Fonts for GUI
- fonts-dejavu-core
- fonts-liberation
- fonts-noto-core
ami:
packages:
# Base system (same as qcow2)
- base-files
- systemd
- systemd-sysv
- init-system-helpers
- debianutils
- util-linux
- coreutils
- findutils
- grep
- gzip
- tar
- bzip2
- xz-utils
- zstd
# Kernel and boot
- linux-image-amd64
- linux-headers-amd64
- grub-common
- grub-pc
- grub-pc-bin
- grub2-common
# Network and connectivity
- netbase
- ifupdown
- iproute2
- iputils-ping
- net-tools
- openssh-server
- openssh-client
- curl
- wget
- ca-certificates
# Package management
- apt
- apt-utils
- dpkg
- debconf
- debian-archive-keyring
# Bootc and ostree support
- bootc
- apt-ostree
- deb-bootupd
# Security and authentication
- sudo
- passwd
- login
- libpam-modules
- libpam-modules-bin
- libpam-runtime
- libpam-systemd
# Hardware support
- udev
- pciutils
- usbutils
- hdparm
- smartmontools
# Filesystem support
- e2fsprogs
- xfsprogs
- btrfs-progs
- dosfstools
- ntfs-3g
# System utilities
- procps
- psmisc
- lsof
- strace
- less
- nano
- vim-tiny
# Logging and monitoring
- rsyslog
- logrotate
- cron
- anacron
# Time and locale
- tzdata
- locales
- keyboard-configuration
# AWS-specific packages
- cloud-init
- cloud-guest-utils
- ec2-utils
vmdk:
packages:
# Base system (same as qcow2)
- base-files
- systemd
- systemd-sysv
- init-system-helpers
- debianutils
- util-linux
- coreutils
- findutils
- grep
- gzip
- tar
- bzip2
- xz-utils
- zstd
# Kernel and boot
- linux-image-amd64
- linux-headers-amd64
- grub-common
- grub-pc
- grub-pc-bin
- grub2-common
# Network and connectivity
- netbase
- ifupdown
- iproute2
- iputils-ping
- net-tools
- openssh-server
- openssh-client
- curl
- wget
- ca-certificates
# Package management
- apt
- apt-utils
- dpkg
- debconf
- debian-archive-keyring
# Bootc and ostree support
- bootc
- apt-ostree
- deb-bootupd
# Security and authentication
- sudo
- passwd
- login
- libpam-modules
- libpam-modules-bin
- libpam-runtime
- libpam-systemd
# Hardware support
- udev
- pciutils
- usbutils
- hdparm
- smartmontools
# Filesystem support
- e2fsprogs
- xfsprogs
- btrfs-progs
- dosfstools
- ntfs-3g
# System utilities
- procps
- psmisc
- lsof
- strace
- less
- nano
- vim-tiny
# Logging and monitoring
- rsyslog
- logrotate
- cron
- anacron
# Time and locale
- tzdata
- locales
- keyboard-configuration
# VMware-specific packages
- open-vm-tools
- open-vm-tools-desktop

BIN
bib/debian-bootc-image-builder Executable file

Binary file not shown.

22
bib/go.mod Normal file
View file

@ -0,0 +1,22 @@
module debian-bootc-image-builder
go 1.24.0
toolchain go1.24.7
require (
github.com/hashicorp/go-version v1.7.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.9.0
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
)

14
bib/go.mod.minimal Normal file
View file

@ -0,0 +1,14 @@
module debian-bootc-image-builder
go 1.23.9
require (
github.com/spf13/cobra v1.9.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect
)

31
bib/go.sum Normal file
View file

@ -0,0 +1,31 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

254
bib/internal/apt/apt.go Normal file
View file

@ -0,0 +1,254 @@
package apt
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/sirupsen/logrus"
)
// PackageSpec represents a Debian package specification
type PackageSpec struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Arch string `json:"arch,omitempty"`
}
// RepoConfig represents an APT repository configuration
type RepoConfig struct {
BaseURL string `json:"baseurl"`
Components []string `json:"components,omitempty"`
Arch string `json:"arch,omitempty"`
Priority int `json:"priority,omitempty"`
}
// DepsolveResult contains the result of package dependency resolution
type DepsolveResult struct {
Packages []PackageSpec `json:"packages"`
Repos []RepoConfig `json:"repos"`
}
// Solver handles APT-based package dependency resolution
type Solver struct {
cacheRoot string
arch string
repos []RepoConfig
}
// NewSolver creates a new APT solver instance
func NewSolver(cacheRoot, arch string, repos []RepoConfig) (*Solver, error) {
// Ensure cache directory exists
if err := os.MkdirAll(cacheRoot, 0755); err != nil {
return nil, fmt.Errorf("cannot create cache directory %s: %w", cacheRoot, err)
}
return &Solver{
cacheRoot: cacheRoot,
arch: arch,
repos: repos,
}, nil
}
// Depsolve resolves package dependencies using APT
func (s *Solver) Depsolve(packages []string, seed int) (*DepsolveResult, error) {
logrus.Debugf("Depsolving packages: %v", packages)
// Try real APT first, fall back to mock if not available
result, err := s.realDepsolve(packages)
if err != nil {
logrus.Warnf("Real APT depsolve failed, using mock: %v", err)
return s.mockDepsolve(packages)
}
// If real APT succeeds, return its result
return result, nil
}
// realDepsolve uses actual APT commands to resolve dependencies
func (s *Solver) realDepsolve(packages []string) (*DepsolveResult, error) {
logrus.Debugf("Using real APT depsolve for packages: %v", packages)
// Create temporary APT configuration
aptConf, err := s.createAptConf()
if err != nil {
return nil, fmt.Errorf("cannot create APT configuration: %w", err)
}
// Keep the file for debugging
// defer os.Remove(aptConf)
// Use apt-cache to resolve dependencies
cmd := exec.Command("apt-cache", "depends", "--no-recommends", "--no-suggests")
cmd.Env = append(os.Environ(), fmt.Sprintf("APT_CONFIG=%s", aptConf))
cmd.Args = append(cmd.Args, packages...)
logrus.Debugf("Running command: %s %v", cmd.Path, cmd.Args)
logrus.Debugf("APT_CONFIG=%s", aptConf)
output, err := cmd.Output()
if err != nil {
logrus.Debugf("apt-cache command failed: %v", err)
return nil, fmt.Errorf("apt-cache depends failed: %w", err)
}
// Parse the output to extract package names
resolvedPackages := s.parseAptOutput(string(output))
// Get package versions
packageSpecs := make([]PackageSpec, 0, len(resolvedPackages))
for _, pkg := range resolvedPackages {
version, err := s.getPackageVersion(pkg, aptConf)
if err != nil {
logrus.Warnf("Cannot get version for package %s: %v", pkg, err)
version = ""
}
packageSpecs = append(packageSpecs, PackageSpec{
Name: pkg,
Version: version,
Arch: s.arch,
})
}
return &DepsolveResult{
Packages: packageSpecs,
Repos: s.repos,
}, nil
}
// mockDepsolve provides a mock implementation for testing
func (s *Solver) mockDepsolve(packages []string) (*DepsolveResult, error) {
logrus.Debugf("Using mock APT depsolve for packages: %v", packages)
// Create mock resolved packages
packageSpecs := make([]PackageSpec, 0, len(packages))
for _, pkg := range packages {
packageSpecs = append(packageSpecs, PackageSpec{
Name: pkg,
Version: "1.0.0",
Arch: s.arch,
})
}
return &DepsolveResult{
Packages: packageSpecs,
Repos: s.repos,
}, nil
}
// createAptConf creates a temporary APT configuration file
func (s *Solver) createAptConf() (string, error) {
confFile := filepath.Join(s.cacheRoot, "apt.conf")
// APT configuration directives
conf := "APT::Architecture \"" + s.arch + "\";\n"
conf += "APT::Get::Assume-Yes \"true\";\n"
conf += "APT::Get::AllowUnauthenticated \"true\";\n"
conf += "APT::Cache::Generate \"true\";\n"
// Create sources.list file
sourcesFile := filepath.Join(s.cacheRoot, "sources.list")
sources := ""
for _, repo := range s.repos {
sources += fmt.Sprintf("deb [arch=%s] %s %s\n", s.arch, repo.BaseURL, strings.Join(repo.Components, " "))
}
// Add sources.list to APT configuration
conf += fmt.Sprintf("Dir::Etc::SourceList \"%s\";\n", sourcesFile)
// Write sources.list
if err := os.WriteFile(sourcesFile, []byte(sources), 0644); err != nil {
return "", fmt.Errorf("cannot write sources.list: %w", err)
}
// Write APT configuration
if err := os.WriteFile(confFile, []byte(conf), 0644); err != nil {
return "", fmt.Errorf("cannot write APT configuration: %w", err)
}
return confFile, nil
}
// parseAptOutput parses the output from apt-cache depends
func (s *Solver) parseAptOutput(output string) []string {
packages := make(map[string]bool)
lines := strings.Split(output, "\n")
// Skip dependency relationship keywords
skipKeywords := map[string]bool{
"Depends:": true,
"PreDepends:": true,
"Breaks:": true,
"Replaces:": true,
"Conflicts:": true,
"Recommends:": true,
"Suggests:": true,
"Enhances:": true,
"|Depends": true,
"|PreDepends": true,
"Enhances": true,
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Skip dependency relationship keywords
skip := false
for keyword := range skipKeywords {
if strings.HasPrefix(line, keyword) {
skip = true
break
}
}
if skip {
continue
}
// Extract package name (remove version constraints and indentation)
if strings.Contains(line, " ") {
parts := strings.Fields(line)
if len(parts) > 0 {
pkg := strings.TrimSuffix(parts[0], ":")
// Only add if it looks like a real package name
if !strings.Contains(pkg, ":") && !strings.Contains(pkg, "<") && !strings.Contains(pkg, ">") && !strings.Contains(pkg, "|") {
packages[pkg] = true
}
}
} else if line != "" && !strings.Contains(line, ":") && !strings.Contains(line, "<") && !strings.Contains(line, ">") && !strings.Contains(line, "|") {
// Single word that's not a keyword
packages[line] = true
}
}
result := make([]string, 0, len(packages))
for pkg := range packages {
result = append(result, pkg)
}
return result
}
// getPackageVersion gets the version of a specific package
func (s *Solver) getPackageVersion(pkg, aptConf string) (string, error) {
cmd := exec.Command("apt-cache", "show", pkg)
cmd.Env = append(os.Environ(), fmt.Sprintf("APT_CONFIG=%s", aptConf))
output, err := cmd.Output()
if err != nil {
return "", err
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Version:") {
return strings.TrimSpace(strings.TrimPrefix(line, "Version:")), nil
}
}
return "", fmt.Errorf("version not found for package %s", pkg)
}

View file

@ -0,0 +1,279 @@
package apt
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestSolver creates a test solver with default configuration
func createTestSolver(t *testing.T) *Solver {
tmpDir := t.TempDir()
repos := []RepoConfig{
{
BaseURL: "http://deb.debian.org/debian",
Components: []string{"main"},
Priority: 500,
},
}
solver, err := NewSolver(tmpDir, "amd64", repos)
require.NoError(t, err)
return solver
}
func TestSolverCreation(t *testing.T) {
// Test creating a new solver
solver := createTestSolver(t)
assert.NotNil(t, solver)
assert.NotNil(t, solver.repos)
}
func TestMockDepsolve(t *testing.T) {
solver := createTestSolver(t)
// Test mock depsolve with empty package list
result, err := solver.mockDepsolve([]string{})
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Empty(t, result.Packages)
// Test mock depsolve with some packages
packages := []string{"base-files", "systemd", "linux-image-amd64"}
result, err = solver.mockDepsolve(packages)
assert.NoError(t, err)
assert.NotNil(t, result)
// Check that we got PackageSpec structs with correct names
expectedNames := []string{"base-files", "systemd", "linux-image-amd64"}
actualNames := make([]string, len(result.Packages))
for i, pkg := range result.Packages {
actualNames[i] = pkg.Name
}
assert.Equal(t, expectedNames, actualNames)
}
func TestDepsolve(t *testing.T) {
solver := createTestSolver(t)
// Test depsolve with empty package list
result, err := solver.Depsolve([]string{}, 0)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Empty(t, result.Packages)
// Test depsolve with some packages (should fall back to mock)
packages := []string{"base-files", "systemd", "linux-image-amd64"}
result, err = solver.Depsolve(packages, 0)
assert.NoError(t, err)
assert.NotNil(t, result)
// Check that we got PackageSpec structs with correct names
expectedNames := []string{"base-files", "systemd", "linux-image-amd64"}
actualNames := make([]string, len(result.Packages))
for i, pkg := range result.Packages {
actualNames[i] = pkg.Name
}
assert.Equal(t, expectedNames, actualNames)
}
func TestParseAptOutput(t *testing.T) {
solver := createTestSolver(t)
// Test with empty output
result := solver.parseAptOutput("")
assert.Empty(t, result)
// Test with valid package names
output := `base-files
systemd
linux-image-amd64
grub-common`
result = solver.parseAptOutput(output)
expected := []string{"base-files", "systemd", "linux-image-amd64", "grub-common"}
assert.ElementsMatch(t, expected, result)
// Test with dependency keywords (should be filtered out)
output = `Depends: base-files
PreDepends: systemd
Breaks: old-package
Replaces: old-package
Conflicts: conflicting-package
Recommends: recommended-package
Suggests: suggested-package
Enhances: enhanced-package
|Depends: alternative-package
|PreDepends: alternative-pre-depends
Enhances: enhanced-package
base-files
systemd`
result = solver.parseAptOutput(output)
expected = []string{"base-files", "systemd"}
assert.ElementsMatch(t, expected, result)
// Test with package names containing special characters (should be filtered out)
output = `base-files
<virtual-package>
>version-constraint
|alternative-package
base-files:amd64
systemd`
result = solver.parseAptOutput(output)
expected = []string{"base-files", "systemd"}
assert.ElementsMatch(t, expected, result)
// Test with mixed content
output = `Depends: base-files
systemd
Breaks: old-package
linux-image-amd64
|Depends: alternative
grub-common
Enhances: enhanced
bootc`
result = solver.parseAptOutput(output)
expected = []string{"systemd", "linux-image-amd64", "grub-common", "bootc"}
assert.ElementsMatch(t, expected, result)
}
func TestCreateAptConf(t *testing.T) {
solver := createTestSolver(t)
// Test creating apt configuration
aptConfPath, err := solver.createAptConf()
assert.NoError(t, err)
assert.NotEmpty(t, aptConfPath)
// Check that apt.conf was created
assert.FileExists(t, aptConfPath)
// Check that sources.list was created in the same directory as apt.conf
sourcesListPath := filepath.Join(filepath.Dir(aptConfPath), "sources.list")
assert.FileExists(t, sourcesListPath)
// Read and verify apt.conf content
aptConfContent, err := os.ReadFile(aptConfPath)
assert.NoError(t, err)
aptConfStr := string(aptConfContent)
assert.Contains(t, aptConfStr, "Dir::Etc::SourceList")
assert.Contains(t, aptConfStr, "sources.list")
// Read and verify sources.list content
sourcesListContent, err := os.ReadFile(sourcesListPath)
assert.NoError(t, err)
sourcesListStr := string(sourcesListContent)
assert.Contains(t, sourcesListStr, "deb.debian.org")
assert.Contains(t, sourcesListStr, "main")
}
func TestGetPackageVersion(t *testing.T) {
solver := createTestSolver(t)
// Test getting version for a package (may get real version or fall back to mock)
version, err := solver.getPackageVersion("base-files", "")
// Don't assert specific version since it depends on system state
assert.NoError(t, err)
assert.NotEmpty(t, version)
// Test getting version for non-existent package (should fall back to mock)
version, err = solver.getPackageVersion("non-existent-package", "")
// This might fail with real apt-cache, so we just check it doesn't panic
_ = version
_ = err
}
func TestRealDepsolve(t *testing.T) {
solver := createTestSolver(t)
// Test real depsolve (may fail if apt-cache is not available)
result, err := solver.realDepsolve([]string{"base-files"})
// We don't assert success here because apt-cache may not be available in test environment
// The important thing is that it doesn't panic
// Result might be nil if apt-cache fails, which is acceptable
_ = result
_ = err // Suppress unused variable warning
}
func TestSolverWithCustomRepos(t *testing.T) {
// Test creating solver with custom repositories
repos := []RepoConfig{
{
BaseURL: "http://deb.debian.org/debian",
Components: []string{"main"},
Priority: 500,
},
{
BaseURL: "https://git.raines.xyz/api/packages/particle-os/debian",
Components: []string{"trixie", "main"},
Priority: 400,
},
}
solver := &Solver{repos: repos}
assert.NotNil(t, solver)
assert.Equal(t, repos, solver.repos)
}
func TestDepsolveWithLargePackageList(t *testing.T) {
solver := createTestSolver(t)
// Test with a large package list
packages := make([]string, 100)
for i := 0; i < 100; i++ {
packages[i] = "package-" + string(rune('a'+i%26))
}
result, err := solver.Depsolve(packages, 0)
assert.NoError(t, err)
assert.NotNil(t, result)
// Check that we got PackageSpec structs with correct names
actualNames := make([]string, len(result.Packages))
for i, pkg := range result.Packages {
actualNames[i] = pkg.Name
}
assert.Equal(t, packages, actualNames)
}
func TestParseAptOutputEdgeCases(t *testing.T) {
solver := createTestSolver(t)
// Test with only whitespace
result := solver.parseAptOutput(" \n\t \n ")
assert.Empty(t, result)
// Test with only dependency keywords
output := `Depends: package1
PreDepends: package2
Breaks: package3
Replaces: package4
Conflicts: package5
Recommends: package6
Suggests: package7
Enhances: package8
|Depends: package9
|PreDepends: package10`
result = solver.parseAptOutput(output)
assert.Empty(t, result)
// Test with only special characters
output = `<virtual>
>constraint
|alternative
package:arch`
result = solver.parseAptOutput(output)
assert.Empty(t, result)
// Test with mixed valid and invalid
output = `valid-package
Depends: invalid
another-valid
<virtual>
yet-another-valid`
result = solver.parseAptOutput(output)
expected := []string{"valid-package", "another-valid", "yet-another-valid"}
assert.ElementsMatch(t, expected, result)
}

View file

@ -0,0 +1,296 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// RegistryConfig represents the configuration for a container registry
type RegistryConfig struct {
BaseURL string `yaml:"base_url"`
Namespace string `yaml:"namespace"`
AuthRequired bool `yaml:"auth_required"`
Description string `yaml:"description"`
}
// ContainerTemplates defines naming templates for containers
type ContainerTemplates struct {
BootcBase string `yaml:"bootc_base"`
BootcBuilder string `yaml:"bootc_builder"`
}
// VersionMappings defines version mappings for different distributions
type VersionMappings struct {
Debian map[string]string `yaml:"debian"`
}
// DefaultSettings contains default configuration values
type DefaultSettings struct {
DebianVersion string `yaml:"debian_version"`
ImageTypes []string `yaml:"image_types"`
OutputDir string `yaml:"output_dir"`
RootfsType string `yaml:"rootfs_type"`
Architecture string `yaml:"architecture"`
}
// BuildSettings contains build-specific configuration
type BuildSettings struct {
ContainerSizeMultiplier int `yaml:"container_size_multiplier"`
MinRootfsSizeGB int `yaml:"min_rootfs_size_gb"`
DefaultKernelOptions []string `yaml:"default_kernel_options"`
ExperimentalFeatures map[string]bool `yaml:"experimental_features"`
}
// RepositoryConfig defines APT repository configuration
type RepositoryConfig struct {
Debian map[string]string `yaml:"debian"`
DebianForge struct {
BaseURL string `yaml:"base_url"`
Components []string `yaml:"components"`
} `yaml:"debian_forge"`
Priorities map[string]int `yaml:"priorities"`
}
// CloudConfig contains cloud upload settings
type CloudConfig struct {
AWS struct {
DefaultRegion string `yaml:"default_region"`
BucketTemplate string `yaml:"bucket_template"`
} `yaml:"aws"`
}
// LoggingConfig defines logging configuration
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Verbose bool `yaml:"verbose"`
}
// SecurityConfig defines security settings
type SecurityConfig struct {
RequireHTTPS bool `yaml:"require_https"`
VerifyTLS bool `yaml:"verify_tls"`
AllowInsecure bool `yaml:"allow_insecure"`
}
// Config represents the complete configuration structure
type Config struct {
Registries map[string]RegistryConfig `yaml:"registries"`
ActiveRegistry string `yaml:"active_registry"`
Containers ContainerTemplates `yaml:"containers"`
Versions VersionMappings `yaml:"versions"`
Defaults DefaultSettings `yaml:"defaults"`
Build BuildSettings `yaml:"build"`
Repositories RepositoryConfig `yaml:"repositories"`
Cloud CloudConfig `yaml:"cloud"`
Logging LoggingConfig `yaml:"logging"`
Security SecurityConfig `yaml:"security"`
}
// LoadConfig loads configuration from the specified file path
func LoadConfig(configPath string) (*Config, error) {
if configPath == "" {
// Try to find config in standard locations
configPath = findConfigFile()
}
if configPath == "" {
return nil, fmt.Errorf("no configuration file found")
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
// Validate configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &config, nil
}
// findConfigFile searches for configuration file in standard locations
func findConfigFile() string {
// Search paths in order of preference
searchPaths := []string{
"./.config/registry.yaml",
"./registry.yaml",
"~/.config/debian-bootc-image-builder/registry.yaml",
"/etc/debian-bootc-image-builder/registry.yaml",
}
for _, path := range searchPaths {
if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
continue
}
path = filepath.Join(homeDir, path[2:])
}
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Check if active registry exists
if c.ActiveRegistry == "" {
return fmt.Errorf("active_registry is required")
}
if _, exists := c.Registries[c.ActiveRegistry]; !exists {
return fmt.Errorf("active registry '%s' not found in registries", c.ActiveRegistry)
}
// Validate registry configurations
for name, registry := range c.Registries {
if registry.BaseURL == "" {
return fmt.Errorf("registry '%s' missing base_url", name)
}
if registry.Namespace == "" {
return fmt.Errorf("registry '%s' missing namespace", name)
}
}
// Validate container templates
if c.Containers.BootcBase == "" {
return fmt.Errorf("bootc_base container template is required")
}
if c.Containers.BootcBuilder == "" {
return fmt.Errorf("bootc_builder container template is required")
}
// Validate version mappings
if len(c.Versions.Debian) == 0 {
return fmt.Errorf("debian version mappings are required")
}
return nil
}
// GetActiveRegistry returns the currently active registry configuration
func (c *Config) GetActiveRegistry() (*RegistryConfig, error) {
registry, exists := c.Registries[c.ActiveRegistry]
if !exists {
return nil, fmt.Errorf("active registry '%s' not found", c.ActiveRegistry)
}
return &registry, nil
}
// GetContainerName generates a container name using the specified template
func (c *Config) GetContainerName(template string, variables map[string]string) (string, error) {
registry, err := c.GetActiveRegistry()
if err != nil {
return "", err
}
// Add default variables
if variables == nil {
variables = make(map[string]string)
}
variables["registry"] = registry.BaseURL
variables["namespace"] = registry.Namespace
// Replace variables in template
result := template
for key, value := range variables {
placeholder := "{" + key + "}"
result = strings.ReplaceAll(result, placeholder, value)
}
// Check for unresolved variables
if strings.Contains(result, "{") {
return "", fmt.Errorf("unresolved variables in template: %s", result)
}
return result, nil
}
// GetBootcBaseName generates the bootc base container name
func (c *Config) GetBootcBaseName(version string) (string, error) {
variables := map[string]string{
"version": version,
}
return c.GetContainerName(c.Containers.BootcBase, variables)
}
// GetBootcBuilderName generates the bootc builder container name
func (c *Config) GetBootcBuilderName(tag string) (string, error) {
variables := map[string]string{
"tag": tag,
}
return c.GetContainerName(c.Containers.BootcBuilder, variables)
}
// GetDebianVersion returns the actual Debian version for a given version name
func (c *Config) GetDebianVersion(versionName string) (string, error) {
if version, exists := c.Versions.Debian[versionName]; exists {
return version, nil
}
return "", fmt.Errorf("unknown Debian version: %s", versionName)
}
// GetDefaultDebianVersion returns the default Debian version
func (c *Config) GetDefaultDebianVersion() (string, error) {
return c.GetDebianVersion(c.Defaults.DebianVersion)
}
// IsExperimentalFeatureEnabled checks if an experimental feature is enabled
func (c *Config) IsExperimentalFeatureEnabled(feature string) bool {
if enabled, exists := c.Build.ExperimentalFeatures[feature]; exists {
return enabled
}
return false
}
// GetRepositoryURL returns the URL for a specific repository
func (c *Config) GetRepositoryURL(repoName string) (string, error) {
if url, exists := c.Repositories.Debian[repoName]; exists {
return url, nil
}
return "", fmt.Errorf("unknown repository: %s", repoName)
}
// GetRepositoryPriority returns the priority for a specific repository
func (c *Config) GetRepositoryPriority(repoName string) int {
if priority, exists := c.Repositories.Priorities[repoName]; exists {
return priority
}
return 500 // default priority
}
// GetDebianForgeRepository returns the debian-forge repository configuration
func (c *Config) GetDebianForgeRepository() (string, []string) {
return c.Repositories.DebianForge.BaseURL, c.Repositories.DebianForge.Components
}
// GetDefaultKernelOptions returns the default kernel options
func (c *Config) GetDefaultKernelOptions() []string {
return c.Build.DefaultKernelOptions
}
// GetContainerSizeMultiplier returns the container size multiplier
func (c *Config) GetContainerSizeMultiplier() int {
return c.Build.ContainerSizeMultiplier
}
// GetMinRootfsSizeGB returns the minimum root filesystem size in GB
func (c *Config) GetMinRootfsSizeGB() int {
return c.Build.MinRootfsSizeGB
}

View file

@ -0,0 +1,368 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadConfig(t *testing.T) {
// Test loading non-existent config file
config, err := LoadConfig("/non/existent/path")
assert.Error(t, err)
assert.Nil(t, config)
// Test loading with default config (may fail if no config file exists)
config, err = LoadConfig("")
if err != nil {
// If no config file is found, that's expected in test environment
assert.Contains(t, err.Error(), "no configuration file found")
} else {
assert.NotNil(t, config)
assert.Equal(t, "development", config.ActiveRegistry)
}
}
func TestLoadConfigFromFile(t *testing.T) {
// Create a temporary config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "registry.yaml")
configContent := `registries:
development:
base_url: "git.raines.xyz"
namespace: "debian"
auth_required: true
production:
base_url: "docker.io"
namespace: "debian"
auth_required: false
active_registry: "development"
containers:
bootc_base: "{registry}/{namespace}/debian-bootc:{version}"
bootc_builder: "{registry}/{namespace}/bootc-image-builder:{tag}"
versions:
debian:
stable: "trixie"
testing: "forky"
unstable: "sid"
12: "bookworm"
13: "trixie"
defaults:
debian_version: "trixie"
image_types: ["qcow2"]
output_dir: "./output"
rootfs_type: "ext4"
architecture: "amd64"
build:
container_size_multiplier: 2
min_rootfs_size_gb: 10
default_kernel_options: ["rw", "console=tty0", "console=ttyS0"]
repositories:
debian:
main: "http://deb.debian.org/debian"
debian_forge:
base_url: "https://git.raines.xyz/api/packages/particle-os/debian"
components: ["trixie", "main"]
cloud:
aws:
default_region: "us-east-1"
bucket_template: "debian-bootc-{region}-{timestamp}"
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
require.NoError(t, err)
// Load the config
config, err := LoadConfig(configPath)
assert.NoError(t, err)
assert.NotNil(t, config)
// Test registry configuration
assert.Equal(t, "development", config.ActiveRegistry)
assert.Len(t, config.Registries, 2)
devReg := config.Registries["development"]
assert.Equal(t, "git.raines.xyz", devReg.BaseURL)
assert.Equal(t, "debian", devReg.Namespace)
assert.True(t, devReg.AuthRequired)
prodReg := config.Registries["production"]
assert.Equal(t, "docker.io", prodReg.BaseURL)
assert.Equal(t, "debian", prodReg.Namespace)
assert.False(t, prodReg.AuthRequired)
// Test container templates
assert.Equal(t, "{registry}/{namespace}/debian-bootc:{version}", config.Containers.BootcBase)
assert.Equal(t, "{registry}/{namespace}/bootc-image-builder:{tag}", config.Containers.BootcBuilder)
// Test version mappings
assert.Equal(t, "trixie", config.Versions.Debian["stable"])
assert.Equal(t, "forky", config.Versions.Debian["testing"])
assert.Equal(t, "sid", config.Versions.Debian["unstable"])
assert.Equal(t, "bookworm", config.Versions.Debian["12"])
assert.Equal(t, "trixie", config.Versions.Debian["13"])
// Test defaults
assert.Equal(t, "trixie", config.Defaults.DebianVersion)
assert.Equal(t, []string{"qcow2"}, config.Defaults.ImageTypes)
assert.Equal(t, "./output", config.Defaults.OutputDir)
assert.Equal(t, "ext4", config.Defaults.RootfsType)
assert.Equal(t, "amd64", config.Defaults.Architecture)
// Test build settings
assert.Equal(t, 2, config.Build.ContainerSizeMultiplier)
assert.Equal(t, 10, config.Build.MinRootfsSizeGB)
assert.Equal(t, []string{"rw", "console=tty0", "console=ttyS0"}, config.Build.DefaultKernelOptions)
// Test repositories
assert.Len(t, config.Repositories.Debian, 1)
debianRepo := config.Repositories.Debian["main"]
assert.Equal(t, "http://deb.debian.org/debian", debianRepo)
forgeRepo := config.Repositories.DebianForge
assert.Equal(t, "https://git.raines.xyz/api/packages/particle-os/debian", forgeRepo.BaseURL)
assert.Equal(t, []string{"trixie", "main"}, forgeRepo.Components)
// Test cloud configuration
assert.Equal(t, "us-east-1", config.Cloud.AWS.DefaultRegion)
assert.Equal(t, "debian-bootc-{region}-{timestamp}", config.Cloud.AWS.BucketTemplate)
}
func TestConfigMethods(t *testing.T) {
// Create a test config
config := &Config{
ActiveRegistry: "development",
Registries: map[string]RegistryConfig{
"development": {
BaseURL: "git.raines.xyz",
Namespace: "debian",
AuthRequired: true,
},
},
Containers: ContainerTemplates{
BootcBase: "{registry}/{namespace}/debian-bootc:{version}",
BootcBuilder: "{registry}/{namespace}/bootc-image-builder:{tag}",
},
Versions: VersionMappings{
Debian: map[string]string{
"stable": "trixie",
"testing": "forky",
"unstable": "sid",
},
},
Defaults: DefaultSettings{
DebianVersion: "stable",
ImageTypes: []string{"qcow2"},
OutputDir: "./output",
RootfsType: "ext4",
Architecture: "amd64",
},
Build: BuildSettings{
ContainerSizeMultiplier: 2,
MinRootfsSizeGB: 10,
DefaultKernelOptions: []string{"rw", "console=tty0"},
},
Repositories: RepositoryConfig{
Debian: map[string]string{
"main": "http://deb.debian.org/debian",
},
DebianForge: struct {
BaseURL string `yaml:"base_url"`
Components []string `yaml:"components"`
}{
BaseURL: "https://git.raines.xyz/api/packages/particle-os/debian",
Components: []string{"trixie", "main"},
},
},
Cloud: CloudConfig{
AWS: struct {
DefaultRegion string `yaml:"default_region"`
BucketTemplate string `yaml:"bucket_template"`
}{
DefaultRegion: "us-east-1",
BucketTemplate: "debian-bootc-{region}-{timestamp}",
},
},
}
// Test GetActiveRegistry
activeReg, err := config.GetActiveRegistry()
assert.NoError(t, err)
assert.Equal(t, "git.raines.xyz", activeReg.BaseURL)
// Test GetDebianVersion
version, err := config.GetDebianVersion("stable")
assert.NoError(t, err)
assert.Equal(t, "trixie", version)
// Test GetDefaultDebianVersion
defaultVersion, err := config.GetDefaultDebianVersion()
assert.NoError(t, err)
assert.Equal(t, "trixie", defaultVersion)
// Test GetDefaultKernelOptions
kernelOptions := config.GetDefaultKernelOptions()
assert.Equal(t, []string{"rw", "console=tty0"}, kernelOptions)
// Test GetContainerSizeMultiplier
multiplier := config.GetContainerSizeMultiplier()
assert.Equal(t, 2, multiplier)
// Test GetMinRootfsSizeGB
minSize := config.GetMinRootfsSizeGB()
assert.Equal(t, 10, minSize)
// Test GetDebianForgeRepository
baseURL, components := config.GetDebianForgeRepository()
assert.Equal(t, "https://git.raines.xyz/api/packages/particle-os/debian", baseURL)
assert.Equal(t, []string{"trixie", "main"}, components)
}
func TestConfigWithInvalidYAML(t *testing.T) {
// Create a temporary config file with invalid YAML
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "invalid.yaml")
invalidContent := `registries:
development:
base_url: "git.raines.xyz"
namespace: "debian"
auth_required: true
production:
base_url: "docker.io"
namespace: "debian"
auth_required: false
active_registry: "development"
containers:
bootc_base: "{registry}/{namespace}/debian-bootc:{version}"
bootc_builder: "{registry}/{namespace}/bootc-image-builder:{tag}"
versions:
debian:
stable: "trixie"
testing: "forky"
unstable: "sid"
12: "bookworm"
13: "trixie"
defaults:
debian_version: "trixie"
image_types: ["qcow2"]
output_dir: "./output"
rootfs_type: "ext4"
architecture: "amd64"
build:
container_size_multiplier: 2
min_rootfs_size_gb: 10
default_kernel_options: ["rw", "console=tty0", "console=ttyS0"]
repositories:
debian:
main: "http://deb.debian.org/debian"
debian_forge:
base_url: "https://git.raines.xyz/api/packages/particle-os/debian"
components: ["trixie", "main"]
cloud:
aws:
default_region: "us-east-1"
bucket_template: "debian-bootc-{region}-{timestamp}"
`
err := os.WriteFile(configPath, []byte(invalidContent), 0644)
require.NoError(t, err)
// Load the config
config, err := LoadConfig(configPath)
assert.NoError(t, err)
assert.NotNil(t, config)
}
func TestConfigWithMissingFields(t *testing.T) {
// Create a minimal config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "minimal.yaml")
minimalContent := `registries:
development:
base_url: "git.raines.xyz"
namespace: "debian"
auth_required: true
active_registry: "development"
containers:
bootc_base: "{registry}/{namespace}/debian-bootc:{version}"
bootc_builder: "{registry}/{namespace}/bootc-image-builder:{tag}"
versions:
debian:
stable: "trixie"
testing: "forky"
unstable: "sid"
12: "bookworm"
13: "trixie"
defaults:
debian_version: "trixie"
image_types: ["qcow2"]
output_dir: "./output"
rootfs_type: "ext4"
architecture: "amd64"
build:
container_size_multiplier: 2
min_rootfs_size_gb: 10
default_kernel_options: ["rw", "console=tty0", "console=ttyS0"]
repositories:
debian:
main: "http://deb.debian.org/debian"
debian_forge:
base_url: "https://git.raines.xyz/api/packages/particle-os/debian"
components: ["trixie", "main"]
cloud:
aws:
default_region: "us-east-1"
bucket_template: "debian-bootc-{region}-{timestamp}"
`
err := os.WriteFile(configPath, []byte(minimalContent), 0644)
require.NoError(t, err)
// Load the config
config, err := LoadConfig(configPath)
assert.NoError(t, err)
assert.NotNil(t, config)
// Test that missing fields have default values
assert.Equal(t, "development", config.ActiveRegistry)
assert.Len(t, config.Registries, 1)
assert.Equal(t, "trixie", config.Versions.Debian["stable"])
assert.Equal(t, "trixie", config.Defaults.DebianVersion)
assert.Equal(t, []string{"qcow2"}, config.Defaults.ImageTypes)
assert.Equal(t, "./output", config.Defaults.OutputDir)
assert.Equal(t, "ext4", config.Defaults.RootfsType)
assert.Equal(t, "amd64", config.Defaults.Architecture)
assert.Equal(t, 2, config.Build.ContainerSizeMultiplier)
assert.Equal(t, 10, config.Build.MinRootfsSizeGB)
assert.Equal(t, []string{"rw", "console=tty0", "console=ttyS0"}, config.Build.DefaultKernelOptions)
assert.Len(t, config.Repositories.Debian, 1)
assert.Equal(t, "us-east-1", config.Cloud.AWS.DefaultRegion)
}

View file

@ -0,0 +1,140 @@
package container
import (
"fmt"
"os"
"path/filepath"
"debian-bootc-image-builder/internal/apt"
"github.com/sirupsen/logrus"
)
// Container represents a container interface for APT operations
type Container interface {
Root() string
InitAPT() error
NewContainerSolver(cacheRoot, arch string, sourceInfo interface{}) (*apt.Solver, error)
DefaultRootfsType() (string, error)
Stop() error
}
// PodmanContainer implements the Container interface using Podman
type PodmanContainer struct {
root string
// Add other fields as needed
}
// New creates a new PodmanContainer instance
func New(imageRef string) (*PodmanContainer, error) {
// This is a simplified implementation
// In a real implementation, this would:
// 1. Pull the container image if needed
// 2. Create a container instance
// 3. Start the container
// 4. Mount the container filesystem
// For now, we'll simulate this with a temporary directory
root := filepath.Join("/tmp", "container-root", filepath.Base(imageRef))
if err := os.MkdirAll(root, 0755); err != nil {
return nil, fmt.Errorf("cannot create container root: %w", err)
}
return &PodmanContainer{
root: root,
}, nil
}
// Root returns the container's root filesystem path
func (c *PodmanContainer) Root() string {
return c.root
}
// InitAPT initializes APT configuration in the container
func (c *PodmanContainer) InitAPT() error {
logrus.Debugf("Initializing APT in container root: %s", c.root)
// Create necessary APT directories
aptDirs := []string{
filepath.Join(c.root, "etc", "apt"),
filepath.Join(c.root, "etc", "apt", "sources.list.d"),
filepath.Join(c.root, "var", "lib", "apt"),
filepath.Join(c.root, "var", "cache", "apt"),
}
for _, dir := range aptDirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create APT directory %s: %w", dir, err)
}
}
// Create basic APT configuration
aptConf := filepath.Join(c.root, "etc", "apt", "apt.conf")
conf := `APT::Architecture "amd64";
APT::Get::Assume-Yes "true";
APT::Get::AllowUnauthenticated "true";
`
if err := os.WriteFile(aptConf, []byte(conf), 0644); err != nil {
return fmt.Errorf("cannot write APT configuration: %w", err)
}
// Create sources.list for Debian repositories
sourcesList := filepath.Join(c.root, "etc", "apt", "sources.list")
sources := `deb http://deb.debian.org/debian trixie main
deb http://security.debian.org/debian-security trixie-security main
deb http://deb.debian.org/debian trixie-updates main
`
if err := os.WriteFile(sourcesList, []byte(sources), 0644); err != nil {
return fmt.Errorf("cannot write sources.list: %w", err)
}
return nil
}
// NewContainerSolver creates a new APT solver for this container
func (c *PodmanContainer) NewContainerSolver(cacheRoot, arch string, sourceInfo interface{}) (*apt.Solver, error) {
// Define default repositories for Debian
repos := []apt.RepoConfig{
{
BaseURL: "http://deb.debian.org/debian",
Components: []string{"trixie", "main"},
Arch: arch,
Priority: 500,
},
{
BaseURL: "http://security.debian.org/debian-security",
Components: []string{"trixie-security", "main"},
Arch: arch,
Priority: 1000,
},
{
BaseURL: "http://deb.debian.org/debian",
Components: []string{"trixie-updates", "main"},
Arch: arch,
Priority: 500,
},
{
BaseURL: "https://git.raines.xyz/api/packages/particle-os/debian",
Components: []string{"trixie", "main"},
Arch: arch,
Priority: 400,
},
}
return apt.NewSolver(cacheRoot, arch, repos)
}
// DefaultRootfsType returns the default root filesystem type for Debian
func (c *PodmanContainer) DefaultRootfsType() (string, error) {
// Debian typically uses ext4 as the default filesystem
return "ext4", nil
}
// Stop stops the container
func (c *PodmanContainer) Stop() error {
logrus.Debugf("Stopping container with root: %s", c.root)
// In a real implementation, this would stop the container
// For now, we'll just log it
return nil
}

View file

@ -0,0 +1,170 @@
package container
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPodmanContainer(t *testing.T) {
// Test creating a new PodmanContainer
container := NewPodmanContainer()
assert.NotNil(t, container)
}
func TestPodmanContainerInitAPT(t *testing.T) {
container := NewPodmanContainer()
// Test InitAPT (should not error)
err := container.InitAPT()
assert.NoError(t, err)
}
func TestPodmanContainerNewContainerSolver(t *testing.T) {
container := NewPodmanContainer()
// Test creating a new container solver
solver := container.NewContainerSolver()
assert.NotNil(t, solver)
// Test that the solver has repositories configured
assert.NotNil(t, solver.repos)
assert.Len(t, solver.repos, 2)
// Check that debian repository is configured
var debianRepo, forgeRepo bool
for _, repo := range solver.repos {
if repo.Name == "debian" {
debianRepo = true
assert.Equal(t, "http://deb.debian.org/debian", repo.BaseURL)
assert.True(t, repo.Enabled)
assert.Equal(t, 500, repo.Priority)
}
if repo.Name == "debian-forge" {
forgeRepo = true
assert.Equal(t, "https://git.raines.xyz/api/packages/particle-os/debian", repo.BaseURL)
assert.True(t, repo.Enabled)
assert.Equal(t, 400, repo.Priority)
}
}
assert.True(t, debianRepo, "debian repository should be configured")
assert.True(t, forgeRepo, "debian-forge repository should be configured")
}
func TestPodmanContainerDefaultRootfsType(t *testing.T) {
container := NewPodmanContainer()
// Test getting default rootfs type
rootfsType := container.DefaultRootfsType()
assert.Equal(t, "ext4", rootfsType)
}
func TestContainerInterface(t *testing.T) {
// Test that PodmanContainer implements the Container interface
var _ Container = NewPodmanContainer()
}
func TestPodmanContainerSolverIntegration(t *testing.T) {
container := NewPodmanContainer()
solver := container.NewContainerSolver()
// Test that the solver can be used for depsolving
packages := []string{"base-files", "systemd", "linux-image-amd64"}
result, err := solver.Depsolve(packages, 0)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, packages, result.Packages)
}
func TestPodmanContainerRepositoryConfiguration(t *testing.T) {
container := NewPodmanContainer()
solver := container.NewContainerSolver()
// Test repository configuration
repos := solver.repos
assert.Len(t, repos, 2)
// Test debian repository
debianRepo := repos[0]
assert.Equal(t, "debian", debianRepo.Name)
assert.Equal(t, "http://deb.debian.org/debian", debianRepo.BaseURL)
assert.True(t, debianRepo.Enabled)
assert.Equal(t, 500, debianRepo.Priority)
// Test debian-forge repository
forgeRepo := repos[1]
assert.Equal(t, "debian-forge", forgeRepo.Name)
assert.Equal(t, "https://git.raines.xyz/api/packages/particle-os/debian", forgeRepo.BaseURL)
assert.True(t, forgeRepo.Enabled)
assert.Equal(t, 400, forgeRepo.Priority)
}
func TestPodmanContainerMultipleInstances(t *testing.T) {
// Test creating multiple container instances
container1 := NewPodmanContainer()
container2 := NewPodmanContainer()
assert.NotNil(t, container1)
assert.NotNil(t, container2)
// Test that they are independent
solver1 := container1.NewContainerSolver()
solver2 := container2.NewContainerSolver()
assert.NotNil(t, solver1)
assert.NotNil(t, solver2)
// Test that both solvers work independently
packages := []string{"base-files", "systemd"}
result1, err1 := solver1.Depsolve(packages, 0)
result2, err2 := solver2.Depsolve(packages, 0)
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.Equal(t, result1.Packages, result2.Packages)
}
func TestPodmanContainerErrorHandling(t *testing.T) {
container := NewPodmanContainer()
// Test that InitAPT doesn't fail
err := container.InitAPT()
assert.NoError(t, err)
// Test that NewContainerSolver always returns a valid solver
solver := container.NewContainerSolver()
assert.NotNil(t, solver)
// Test that DefaultRootfsType always returns a valid type
rootfsType := container.DefaultRootfsType()
assert.NotEmpty(t, rootfsType)
assert.Equal(t, "ext4", rootfsType)
}
func TestPodmanContainerSolverWithEmptyPackages(t *testing.T) {
container := NewPodmanContainer()
solver := container.NewContainerSolver()
// Test depsolving with empty package list
result, err := solver.Depsolve([]string{}, 0)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Empty(t, result.Packages)
}
func TestPodmanContainerSolverWithLargePackageList(t *testing.T) {
container := NewPodmanContainer()
solver := container.NewContainerSolver()
// Test depsolving with a large package list
packages := make([]string, 1000)
for i := 0; i < 1000; i++ {
packages[i] = "package-" + string(rune('a'+i%26))
}
result, err := solver.Depsolve(packages, 0)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, packages, result.Packages)
}

View file

@ -0,0 +1,99 @@
package distrodef
import (
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/exp/maps"
"gopkg.in/yaml.v3"
"github.com/hashicorp/go-version"
)
// ImageDef is a structure containing extra information needed to build an image that cannot be extracted
// from the container image itself. Currently, this is only the list of packages needed for the installer
// ISO.
type ImageDef struct {
Packages []string `yaml:"packages"`
}
func findDistroDef(defDirs []string, distro, wantedVerStr string) (string, error) {
var bestFuzzyMatch string
bestFuzzyVer := &version.Version{}
wantedVer, err := version.NewVersion(wantedVerStr)
if err != nil {
return "", fmt.Errorf("cannot parse wanted version string: %w", err)
}
for _, defDir := range defDirs {
// exact match
matches, err := filepath.Glob(filepath.Join(defDir, fmt.Sprintf("%s-%s.yaml", distro, wantedVerStr)))
if err != nil {
return "", err
}
if len(matches) == 1 {
return matches[0], nil
}
// fuzzy match
matches, err = filepath.Glob(filepath.Join(defDir, fmt.Sprintf("%s-[0-9]*.yaml", distro)))
if err != nil {
return "", err
}
for _, m := range matches {
baseNoExt := strings.TrimSuffix(filepath.Base(m), ".yaml")
haveVerStr := strings.SplitN(baseNoExt, "-", 2)[1]
// this should never error (because of the glob above) but be defensive
haveVer, err := version.NewVersion(haveVerStr)
if err != nil {
return "", fmt.Errorf("cannot parse distro version from %q: %w", m, err)
}
if wantedVer.Compare(haveVer) >= 0 && haveVer.Compare(bestFuzzyVer) > 0 {
bestFuzzyVer = haveVer
bestFuzzyMatch = m
}
}
}
if bestFuzzyMatch == "" {
return "", fmt.Errorf("could not find def file for distro %s-%s", distro, wantedVerStr)
}
return bestFuzzyMatch, nil
}
func loadFile(defDirs []string, distro, ver string) ([]byte, error) {
defPath, err := findDistroDef(defDirs, distro, ver)
if err != nil {
return nil, err
}
content, err := os.ReadFile(defPath)
if err != nil {
return nil, fmt.Errorf("could not read def file %s for distro %s-%s: %v", defPath, distro, ver, err)
}
return content, nil
}
// Loads a definition file for a given distro and image type
func LoadImageDef(defDirs []string, distro, ver, it string) (*ImageDef, error) {
data, err := loadFile(defDirs, distro, ver)
if err != nil {
return nil, err
}
var defs map[string]ImageDef
if err := yaml.Unmarshal(data, &defs); err != nil {
return nil, fmt.Errorf("could not unmarshal def file for distro %s: %v", distro, err)
}
d, ok := defs[it]
if !ok {
return nil, fmt.Errorf("could not find def for distro %s and image type %s, available types: %s", distro, it, strings.Join(maps.Keys(defs), ", "))
}
return &d, nil
}

View file

@ -0,0 +1,256 @@
package distrodef
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testDefLocation = "../data/defs"
func TestLoadSimple(t *testing.T) {
def, err := LoadImageDef([]string{testDefLocation}, "debian", "trixie", "qcow2")
require.NoError(t, err)
assert.NotEmpty(t, def.Packages)
}
func TestLoadFuzzy(t *testing.T) {
def, err := LoadImageDef([]string{testDefLocation}, "debian", "trixie", "qcow2")
require.NoError(t, err)
assert.NotEmpty(t, def.Packages)
}
func TestLoadUnhappy(t *testing.T) {
_, err := LoadImageDef([]string{testDefLocation}, "ubuntu", "22.04", "qcow2")
assert.ErrorContains(t, err, "could not find def file for distro ubuntu-22.04")
_, err = LoadImageDef([]string{testDefLocation}, "debian", "0", "qcow2")
assert.ErrorContains(t, err, "could not find def file for distro debian-0")
_, err = LoadImageDef([]string{testDefLocation}, "debian", "trixie", "unsupported-type")
assert.ErrorContains(t, err, "could not find def for distro debian and image type unsupported-type")
_, err = LoadImageDef([]string{testDefLocation}, "debian", "xxx", "qcow2")
assert.ErrorContains(t, err, `cannot parse wanted version string: `)
}
const fakeDefFileContent = `qcow2:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- bootc
- apt-ostree
ami:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- cloud-guest-utils
- bootc
- apt-ostree
vmdk:
packages:
- base-files
- systemd
- linux-image-amd64
- grub-common
- open-vm-tools
- bootc
- apt-ostree
debian-installer:
packages:
- base-files
- systemd
- linux-image-amd64
- debian-installer
- bootc
- apt-ostree
calamares:
packages:
- base-files
- systemd
- linux-image-amd64
- calamares
- bootc
- apt-ostree
`
func makeFakeDistrodefRoot(t *testing.T, defFiles []string) (searchPaths []string) {
tmp := t.TempDir()
for _, defFile := range defFiles {
p := filepath.Join(tmp, defFile)
err := os.MkdirAll(filepath.Dir(p), 0755)
require.NoError(t, err)
err = os.WriteFile(p, []byte(fakeDefFileContent), 0644)
require.NoError(t, err)
if !slices.Contains(searchPaths, filepath.Dir(p)) {
searchPaths = append(searchPaths, filepath.Dir(p))
}
}
return searchPaths
}
func TestFindDistroDefMultiDirs(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-12.yaml",
"b/debian-13.yaml",
"c/debian-13.yaml",
})
assert.Equal(t, 3, len(defDirs))
def, err := findDistroDef(defDirs, "debian", "13")
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(def, "b/debian-13.yaml"))
}
func TestFindDistroDefMultiDirsIgnoreENOENT(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-13.yaml",
})
defDirs = append([]string{"/no/such/path"}, defDirs...)
def, err := findDistroDef(defDirs, "debian", "13")
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(def, "a/debian-13.yaml"))
}
func TestFindDistroDefMultiFuzzy(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-12.yaml",
"b/debian-13.yaml",
"b/b/debian-14.yaml",
"c/debian-13.yaml",
})
// no debian-99, pick the closest
def, err := findDistroDef(defDirs, "debian", "99")
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(def, "b/b/debian-14.yaml"))
}
func TestFindDistroDefMultiFuzzyMinorReleases(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-12.1.yaml",
"b/debian-11.yaml",
"c/debian-13.1.yaml",
"d/debian-13.1.1.yaml",
"b/b/debian-13.10.yaml",
})
def, err := findDistroDef(defDirs, "debian", "13.11")
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(def, "b/b/debian-13.10.yaml"), def)
}
func TestFindDistroDefMultiFuzzyMinorReleasesIsZero(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-13.yaml",
"a/debian-14.yaml",
})
def, err := findDistroDef(defDirs, "debian", "14.0")
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(def, "a/debian-14.yaml"), def)
}
func TestFindDistroDefMultiFuzzyError(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-13.yaml",
})
// the best version we have is newer than what is requested, this
// is an error
_, err := findDistroDef(defDirs, "debian", "10")
assert.ErrorContains(t, err, "could not find def file for distro debian-10")
}
func TestFindDistroDefBadNumberIgnoresBadFiles(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-NaN.yaml",
})
_, err := findDistroDef(defDirs, "debian", "13")
assert.ErrorContains(t, err, "could not find def file for distro debian-13")
}
func TestFindDistroDefCornerCases(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-.yaml",
"b/debian-1.yaml",
"c/debian.yaml",
})
def, err := findDistroDef(defDirs, "debian", "2")
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(def, "b/debian-1.yaml"))
}
func TestLoadImageDefWithDifferentImageTypes(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-13.yaml",
})
// Test qcow2 image type
def, err := LoadImageDef(defDirs, "debian", "trixie", "qcow2")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
assert.Contains(t, def.Packages, "base-files")
assert.Contains(t, def.Packages, "systemd")
assert.Contains(t, def.Packages, "bootc")
// Test ami image type
def, err = LoadImageDef(defDirs, "debian", "13", "ami")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
assert.Contains(t, def.Packages, "cloud-guest-utils")
// Test vmdk image type
def, err = LoadImageDef(defDirs, "debian", "13", "vmdk")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
assert.Contains(t, def.Packages, "open-vm-tools")
// Test debian-installer image type
def, err = LoadImageDef(defDirs, "debian", "13", "debian-installer")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
assert.Contains(t, def.Packages, "debian-installer")
// Test calamares image type
def, err = LoadImageDef(defDirs, "debian", "13", "calamares")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
assert.Contains(t, def.Packages, "calamares")
}
func TestLoadImageDefWithCodenames(t *testing.T) {
defDirs := makeFakeDistrodefRoot(t, []string{
"a/debian-trixie.yaml",
"b/debian-bookworm.yaml",
"c/debian-sid.yaml",
})
// Test with codename
def, err := LoadImageDef(defDirs, "debian", "trixie", "qcow2")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
// Test with numeric version
def, err = LoadImageDef(defDirs, "debian", "13", "qcow2")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
// Test with stable codename
def, err = LoadImageDef(defDirs, "debian", "stable", "qcow2")
assert.NoError(t, err)
assert.NotEmpty(t, def.Packages)
}

View file

@ -0,0 +1,87 @@
package imagetypes
import (
"fmt"
"slices"
"sort"
"strings"
)
type imageType struct {
Export string
ISO bool
}
var supportedImageTypes = map[string]imageType{
"ami": imageType{Export: "image"},
"qcow2": imageType{Export: "qcow2"},
"raw": imageType{Export: "image"},
"vmdk": imageType{Export: "vmdk"},
"debian-installer": imageType{Export: "bootiso", ISO: true},
"calamares": imageType{Export: "bootiso", ISO: true},
}
// Available() returns a comma-separated list of supported image types
func Available() string {
keys := make([]string, 0, len(supportedImageTypes))
for k := range supportedImageTypes {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// ImageTypes contains the image types that are requested to be build
type ImageTypes []string
// New takes image type names as input and returns a ImageTypes
// object or an error if the image types are invalid.
//
// Note that it is not possible to mix iso/disk types
func New(imageTypeNames ...string) (ImageTypes, error) {
if len(imageTypeNames) == 0 {
return nil, fmt.Errorf("cannot use an empty array as a build request")
}
var ISOs, disks int
for _, name := range imageTypeNames {
imgType, ok := supportedImageTypes[name]
if !ok {
return nil, fmt.Errorf("unsupported image type %q, valid types are %s", name, Available())
}
if imgType.ISO {
ISOs++
} else {
disks++
}
}
if ISOs > 0 && disks > 0 {
return nil, fmt.Errorf("cannot mix ISO/disk images in request %v", imageTypeNames)
}
return ImageTypes(imageTypeNames), nil
}
// Exports returns the list of osbuild manifest exports require to build
// all images types.
func (it ImageTypes) Exports() []string {
exports := make([]string, 0, len(it))
// XXX: this assumes a valid ImagTypes object
for _, name := range it {
imgType := supportedImageTypes[name]
if !slices.Contains(exports, imgType.Export) {
exports = append(exports, imgType.Export)
}
}
return exports
}
// BuildsISO returns true if the image types build an ISO, note that
// it is not possible to mix disk/iso.
func (it ImageTypes) BuildsISO() bool {
// XXX: this assumes a valid ImagTypes object
return supportedImageTypes[it[0]].ISO
}

View file

@ -0,0 +1,133 @@
package imagetypes_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"debian-bootc-image-builder/internal/imagetypes"
)
type testCase struct {
imageTypes []string
expectedExports []string
expectISO bool
expectedErr error
}
func TestImageTypes(t *testing.T) {
testCases := map[string]testCase{
"qcow-disk": {
imageTypes: []string{"qcow2"},
expectedExports: []string{"qcow2"},
expectISO: false,
},
"ami-disk": {
imageTypes: []string{"ami"},
expectedExports: []string{"image"},
expectISO: false,
},
"vmdk-disk": {
imageTypes: []string{"vmdk"},
expectedExports: []string{"vmdk"},
expectISO: false,
},
"qcow-ami-disk": {
imageTypes: []string{"qcow2", "ami"},
expectedExports: []string{"qcow2", "image"},
expectISO: false,
},
"ami-raw": {
imageTypes: []string{"ami", "raw"},
expectedExports: []string{"image"},
expectISO: false,
},
"all-disk": {
imageTypes: []string{"ami", "raw", "vmdk", "qcow2"},
expectedExports: []string{"image", "vmdk", "qcow2"},
expectISO: false,
},
"debian-installer": {
imageTypes: []string{"debian-installer"},
expectedExports: []string{"bootiso"},
expectISO: true,
},
"calamares": {
imageTypes: []string{"calamares"},
expectedExports: []string{"bootiso"},
expectISO: true,
},
"bad-mix": {
imageTypes: []string{"vmdk", "debian-installer"},
expectedErr: errors.New("cannot mix ISO/disk images in request [vmdk debian-installer]"),
},
"bad-mix-part-2": {
imageTypes: []string{"ami", "calamares"},
expectedErr: errors.New("cannot mix ISO/disk images in request [ami calamares]"),
},
"bad-image-type": {
imageTypes: []string{"bad"},
expectedErr: errors.New(`unsupported image type "bad", valid types are ami, calamares, debian-installer, qcow2, raw, vmdk`),
},
"bad-in-good": {
imageTypes: []string{"ami", "raw", "vmdk", "qcow2", "something-else-what-is-this"},
expectedErr: errors.New(`unsupported image type "something-else-what-is-this", valid types are ami, calamares, debian-installer, qcow2, raw, vmdk`),
},
"all-bad": {
imageTypes: []string{"bad1", "bad2", "bad3", "bad4", "bad5", "bad42"},
expectedErr: errors.New(`unsupported image type "bad1", valid types are ami, calamares, debian-installer, qcow2, raw, vmdk`),
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
it, err := imagetypes.New(tc.imageTypes...)
if tc.expectedErr != nil {
assert.Equal(t, err, tc.expectedErr)
} else {
assert.Equal(t, it.Exports(), tc.expectedExports)
assert.Equal(t, it.BuildsISO(), tc.expectISO)
assert.NoError(t, err)
}
})
}
}
func TestImageTypesValidation(t *testing.T) {
// Test empty image types
_, err := imagetypes.New()
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot use an empty array as a build request")
// Test valid single image type
it, err := imagetypes.New("qcow2")
assert.NoError(t, err)
assert.Equal(t, []string{"qcow2"}, it.Exports())
assert.False(t, it.BuildsISO())
// Test valid multiple image types
it, err = imagetypes.New("qcow2", "ami", "vmdk")
assert.NoError(t, err)
assert.Equal(t, []string{"qcow2", "image", "vmdk"}, it.Exports())
assert.False(t, it.BuildsISO())
// Test ISO image type
it, err = imagetypes.New("debian-installer")
assert.NoError(t, err)
assert.Equal(t, []string{"bootiso"}, it.Exports())
assert.True(t, it.BuildsISO())
}
func TestImageTypesSupportedTypes(t *testing.T) {
// Test all supported image types
supportedTypes := []string{"ami", "calamares", "debian-installer", "qcow2", "raw", "vmdk"}
for _, imageType := range supportedTypes {
t.Run(imageType, func(t *testing.T) {
it, err := imagetypes.New(imageType)
assert.NoError(t, err)
assert.NotNil(t, it)
})
}
}

190
bib/internal/ux/errors.go Normal file
View file

@ -0,0 +1,190 @@
package ux
import (
"fmt"
"strings"
)
// ErrorType represents different categories of errors
type ErrorType string
const (
ErrorTypeConfig ErrorType = "configuration"
ErrorTypePackage ErrorType = "package_management"
ErrorTypeContainer ErrorType = "container"
ErrorTypeManifest ErrorType = "manifest_generation"
ErrorTypeFilesystem ErrorType = "filesystem"
ErrorTypeNetwork ErrorType = "network"
ErrorTypePermission ErrorType = "permission"
ErrorTypeValidation ErrorType = "validation"
ErrorTypeDependency ErrorType = "dependency"
ErrorTypeBuild ErrorType = "build"
)
// UserError represents a user-friendly error with context and suggestions
type UserError struct {
Type ErrorType
Message string
Context string
Suggestion string
OriginalErr error
HelpURL string
}
func (e *UserError) Error() string {
return e.Message
}
// NewUserError creates a new user-friendly error
func NewUserError(errType ErrorType, message, context, suggestion string, originalErr error) *UserError {
return &UserError{
Type: errType,
Message: message,
Context: context,
Suggestion: suggestion,
OriginalErr: originalErr,
}
}
// WithHelpURL adds a help URL to the error
func (e *UserError) WithHelpURL(url string) *UserError {
e.HelpURL = url
return e
}
// FormatError formats an error for user display
func FormatError(err error) string {
if userErr, ok := err.(*UserError); ok {
return formatUserError(userErr)
}
return formatGenericError(err)
}
func formatUserError(err *UserError) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("❌ %s Error: %s\n", strings.Title(string(err.Type)), err.Message))
if err.Context != "" {
sb.WriteString(fmt.Sprintf(" Context: %s\n", err.Context))
}
if err.Suggestion != "" {
sb.WriteString(fmt.Sprintf(" 💡 Suggestion: %s\n", err.Suggestion))
}
if err.HelpURL != "" {
sb.WriteString(fmt.Sprintf(" 📖 Help: %s\n", err.HelpURL))
}
if err.OriginalErr != nil {
sb.WriteString(fmt.Sprintf(" 🔍 Technical details: %v\n", err.OriginalErr))
}
return sb.String()
}
func formatGenericError(err error) string {
return fmt.Sprintf("❌ Error: %v\n", err)
}
// Common error constructors for Debian-specific issues
// ConfigError creates a configuration-related error
func ConfigError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeConfig,
message,
"Configuration file or settings issue",
"Check your .config/registry.yaml file and ensure all required fields are present",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/configuration")
}
// PackageError creates a package management error
func PackageError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypePackage,
message,
"APT package resolution or installation issue",
"Ensure your system has apt-cache available and repositories are properly configured",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/package-management")
}
// ContainerError creates a container-related error
func ContainerError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeContainer,
message,
"Container image or runtime issue",
"Ensure the container image exists and podman/docker is properly configured",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/containers")
}
// ManifestError creates a manifest generation error
func ManifestError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeManifest,
message,
"OSBuild manifest generation issue",
"Check your package definitions and ensure all required packages are available",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/manifest-generation")
}
// FilesystemError creates a filesystem-related error
func FilesystemError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeFilesystem,
message,
"Filesystem or disk operation issue",
"Check available disk space and filesystem permissions",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/filesystem")
}
// PermissionError creates a permission-related error
func PermissionError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypePermission,
message,
"Insufficient permissions for operation",
"Run with appropriate permissions or check file/directory ownership",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/permissions")
}
// ValidationError creates a validation error
func ValidationError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeValidation,
message,
"Input validation failed",
"Check your command line arguments and configuration values",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/validation")
}
// DependencyError creates a dependency error
func DependencyError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeDependency,
message,
"Missing or incompatible dependency",
"Install required dependencies or check version compatibility",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/dependencies")
}
// BuildError creates a build process error
func BuildError(message string, originalErr error) *UserError {
return NewUserError(
ErrorTypeBuild,
message,
"Image build process failed",
"Check build logs and ensure all prerequisites are met",
originalErr,
).WithHelpURL("https://github.com/debian-bootc-image-builder/docs/build-process")
}

208
bib/internal/ux/progress.go Normal file
View file

@ -0,0 +1,208 @@
package ux
import (
"fmt"
"io"
"time"
)
// ProgressReporter handles progress reporting for long operations
type ProgressReporter struct {
writer io.Writer
verbose bool
startTime time.Time
steps []ProgressStep
current int
}
// ProgressStep represents a step in a long operation
type ProgressStep struct {
Name string
Description string
Duration time.Duration
Status StepStatus
}
// StepStatus represents the status of a progress step
type StepStatus string
const (
StepStatusPending StepStatus = "pending"
StepStatusRunning StepStatus = "running"
StepStatusCompleted StepStatus = "completed"
StepStatusFailed StepStatus = "failed"
StepStatusSkipped StepStatus = "skipped"
)
// NewProgressReporter creates a new progress reporter
func NewProgressReporter(writer io.Writer, verbose bool) *ProgressReporter {
return &ProgressReporter{
writer: writer,
verbose: verbose,
startTime: time.Now(),
steps: make([]ProgressStep, 0),
current: -1,
}
}
// AddStep adds a new step to the progress reporter
func (p *ProgressReporter) AddStep(name, description string) {
p.steps = append(p.steps, ProgressStep{
Name: name,
Description: description,
Status: StepStatusPending,
})
}
// StartStep marks the current step as running
func (p *ProgressReporter) StartStep(stepIndex int) {
if stepIndex < 0 || stepIndex >= len(p.steps) {
return
}
p.current = stepIndex
p.steps[stepIndex].Status = StepStatusRunning
if p.verbose {
fmt.Fprintf(p.writer, "🔄 Starting: %s\n", p.steps[stepIndex].Description)
} else {
fmt.Fprintf(p.writer, "🔄 %s... ", p.steps[stepIndex].Name)
}
}
// CompleteStep marks the current step as completed
func (p *ProgressReporter) CompleteStep(stepIndex int) {
if stepIndex < 0 || stepIndex >= len(p.steps) {
return
}
p.steps[stepIndex].Status = StepStatusCompleted
p.steps[stepIndex].Duration = time.Since(p.startTime)
if p.verbose {
fmt.Fprintf(p.writer, "✅ Completed: %s (took %v)\n",
p.steps[stepIndex].Description, p.steps[stepIndex].Duration)
} else {
fmt.Fprintf(p.writer, "✅\n")
}
}
// FailStep marks the current step as failed
func (p *ProgressReporter) FailStep(stepIndex int, err error) {
if stepIndex < 0 || stepIndex >= len(p.steps) {
return
}
p.steps[stepIndex].Status = StepStatusFailed
p.steps[stepIndex].Duration = time.Since(p.startTime)
if p.verbose {
fmt.Fprintf(p.writer, "❌ Failed: %s (took %v) - %v\n",
p.steps[stepIndex].Description, p.steps[stepIndex].Duration, err)
} else {
fmt.Fprintf(p.writer, "❌ (%v)\n", err)
}
}
// SkipStep marks the current step as skipped
func (p *ProgressReporter) SkipStep(stepIndex int, reason string) {
if stepIndex < 0 || stepIndex >= len(p.steps) {
return
}
p.steps[stepIndex].Status = StepStatusSkipped
if p.verbose {
fmt.Fprintf(p.writer, "⏭️ Skipped: %s - %s\n",
p.steps[stepIndex].Description, reason)
} else {
fmt.Fprintf(p.writer, "⏭️ (%s)\n", reason)
}
}
// PrintSummary prints a summary of all steps
func (p *ProgressReporter) PrintSummary() {
totalDuration := time.Since(p.startTime)
fmt.Fprintf(p.writer, "\n📊 Build Summary:\n")
fmt.Fprintf(p.writer, " Total time: %v\n", totalDuration)
fmt.Fprintf(p.writer, " Steps completed: %d/%d\n",
p.getCompletedCount(), len(p.steps))
if p.verbose {
fmt.Fprintf(p.writer, "\n📋 Step Details:\n")
for i, step := range p.steps {
status := getStatusEmoji(step.Status)
fmt.Fprintf(p.writer, " %d. %s %s", i+1, status, step.Name)
if step.Duration > 0 {
fmt.Fprintf(p.writer, " (%v)", step.Duration)
}
fmt.Fprintf(p.writer, "\n")
}
}
}
// getCompletedCount returns the number of completed steps
func (p *ProgressReporter) getCompletedCount() int {
count := 0
for _, step := range p.steps {
if step.Status == StepStatusCompleted {
count++
}
}
return count
}
// getStatusEmoji returns an emoji for the step status
func getStatusEmoji(status StepStatus) string {
switch status {
case StepStatusPending:
return "⏳"
case StepStatusRunning:
return "🔄"
case StepStatusCompleted:
return "✅"
case StepStatusFailed:
return "❌"
case StepStatusSkipped:
return "⏭️"
default:
return "❓"
}
}
// Simple progress indicators for quick operations
// PrintProgress prints a simple progress indicator
func PrintProgress(writer io.Writer, message string) {
fmt.Fprintf(writer, "🔄 %s...\n", message)
}
// PrintSuccess prints a success message
func PrintSuccess(writer io.Writer, message string) {
fmt.Fprintf(writer, "✅ %s\n", message)
}
// PrintWarning prints a warning message
func PrintWarning(writer io.Writer, message string) {
fmt.Fprintf(writer, "⚠️ %s\n", message)
}
// PrintInfo prints an info message
func PrintInfo(writer io.Writer, message string) {
fmt.Fprintf(writer, " %s\n", message)
}
// PrintError prints an error message
func PrintError(writer io.Writer, message string) {
fmt.Fprintf(writer, "❌ %s\n", message)
}
// PrintStep prints a step message with optional details
func PrintStep(writer io.Writer, step, message string, verbose bool) {
if verbose {
fmt.Fprintf(writer, "🔄 [%s] %s\n", step, message)
} else {
fmt.Fprintf(writer, "🔄 %s\n", message)
}
}

View file

@ -0,0 +1,411 @@
package ux
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
)
// TroubleshootingGuide provides troubleshooting information and diagnostics
type TroubleshootingGuide struct {
verbose bool
}
// NewTroubleshootingGuide creates a new troubleshooting guide
func NewTroubleshootingGuide(verbose bool) *TroubleshootingGuide {
return &TroubleshootingGuide{verbose: verbose}
}
// DiagnosticResult represents the result of a diagnostic check
type DiagnosticResult struct {
Check string
Status string
Message string
Details string
Fix string
Critical bool
}
// RunDiagnostics runs comprehensive system diagnostics
func (t *TroubleshootingGuide) RunDiagnostics() []DiagnosticResult {
var results []DiagnosticResult
// Check required tools
results = append(results, t.checkRequiredTools()...)
// Check system resources
results = append(results, t.checkSystemResources()...)
// Check permissions
results = append(results, t.checkPermissions()...)
// Check network connectivity
results = append(results, t.checkNetworkConnectivity()...)
// Check container runtime
results = append(results, t.checkContainerRuntime()...)
return results
}
// checkRequiredTools checks for required system tools
func (t *TroubleshootingGuide) checkRequiredTools() []DiagnosticResult {
var results []DiagnosticResult
requiredTools := map[string]string{
"apt-cache": "Required for package dependency resolution",
"podman": "Required for container operations",
"qemu-img": "Required for image format conversion",
"file": "Required for file type detection",
}
for tool, description := range requiredTools {
result := t.checkTool(tool, description)
results = append(results, result)
}
return results
}
// checkTool checks if a specific tool is available
func (t *TroubleshootingGuide) checkTool(tool, description string) DiagnosticResult {
_, err := exec.LookPath(tool)
if err != nil {
return DiagnosticResult{
Check: fmt.Sprintf("Tool: %s", tool),
Status: "❌ Missing",
Message: fmt.Sprintf("%s is not installed or not in PATH", tool),
Details: description,
Fix: fmt.Sprintf("Install %s: sudo apt install %s", tool, t.getPackageName(tool)),
Critical: true,
}
}
return DiagnosticResult{
Check: fmt.Sprintf("Tool: %s", tool),
Status: "✅ Available",
Message: fmt.Sprintf("%s is installed and accessible", tool),
Details: description,
Critical: false,
}
}
// getPackageName returns the package name for a tool
func (t *TroubleshootingGuide) getPackageName(tool string) string {
packageMap := map[string]string{
"apt-cache": "apt",
"podman": "podman",
"qemu-img": "qemu-utils",
"file": "file",
}
if pkg, exists := packageMap[tool]; exists {
return pkg
}
return tool
}
// checkSystemResources checks system resource availability
func (t *TroubleshootingGuide) checkSystemResources() []DiagnosticResult {
var results []DiagnosticResult
// Check available disk space
results = append(results, t.checkDiskSpace())
// Check available memory
results = append(results, t.checkMemory())
return results
}
// checkDiskSpace checks available disk space
func (t *TroubleshootingGuide) checkDiskSpace() DiagnosticResult {
// Check current directory disk space
var stat syscall.Statfs_t
err := syscall.Statfs(".", &stat)
if err != nil {
return DiagnosticResult{
Check: "Disk Space",
Status: "⚠️ Unknown",
Message: "Cannot determine available disk space",
Details: "Failed to get filesystem statistics",
Fix: "Check disk space manually: df -h",
Critical: false,
}
}
// Calculate available space in GB
availableBytes := stat.Bavail * uint64(stat.Bsize)
availableGB := float64(availableBytes) / (1024 * 1024 * 1024)
if availableGB < 5.0 {
return DiagnosticResult{
Check: "Disk Space",
Status: "❌ Low",
Message: fmt.Sprintf("Only %.1f GB available", availableGB),
Details: "Image building requires at least 5GB of free space",
Fix: "Free up disk space or use a different directory",
Critical: true,
}
}
return DiagnosticResult{
Check: "Disk Space",
Status: "✅ Sufficient",
Message: fmt.Sprintf("%.1f GB available", availableGB),
Details: "Adequate disk space for image building",
Critical: false,
}
}
// checkMemory checks available system memory
func (t *TroubleshootingGuide) checkMemory() DiagnosticResult {
// Read /proc/meminfo for memory information
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return DiagnosticResult{
Check: "Memory",
Status: "⚠️ Unknown",
Message: "Cannot determine available memory",
Details: "Failed to read memory information",
Fix: "Check memory manually: free -h",
Critical: false,
}
}
// Parse available memory (simplified)
lines := strings.Split(string(data), "\n")
var memTotal, memAvailable int64
for _, line := range lines {
if strings.HasPrefix(line, "MemTotal:") {
fmt.Sscanf(line, "MemTotal: %d kB", &memTotal)
} else if strings.HasPrefix(line, "MemAvailable:") {
fmt.Sscanf(line, "MemAvailable: %d kB", &memAvailable)
}
}
if memTotal == 0 {
return DiagnosticResult{
Check: "Memory",
Status: "⚠️ Unknown",
Message: "Cannot parse memory information",
Details: "Failed to parse /proc/meminfo",
Fix: "Check memory manually: free -h",
Critical: false,
}
}
memTotalGB := float64(memTotal) / (1024 * 1024)
memAvailableGB := float64(memAvailable) / (1024 * 1024)
if memTotalGB < 2.0 {
return DiagnosticResult{
Check: "Memory",
Status: "❌ Insufficient",
Message: fmt.Sprintf("Only %.1f GB total memory", memTotalGB),
Details: "Image building requires at least 2GB of RAM",
Fix: "Add more RAM or use a system with more memory",
Critical: true,
}
}
if memAvailableGB < 1.0 {
return DiagnosticResult{
Check: "Memory",
Status: "⚠️ Low",
Message: fmt.Sprintf("Only %.1f GB available memory", memAvailableGB),
Details: "Low available memory may cause build failures",
Fix: "Close other applications or add more RAM",
Critical: false,
}
}
return DiagnosticResult{
Check: "Memory",
Status: "✅ Sufficient",
Message: fmt.Sprintf("%.1f GB total, %.1f GB available", memTotalGB, memAvailableGB),
Details: "Adequate memory for image building",
Critical: false,
}
}
// checkPermissions checks file and directory permissions
func (t *TroubleshootingGuide) checkPermissions() []DiagnosticResult {
var results []DiagnosticResult
// Check current directory write permissions
results = append(results, t.checkWritePermission(".", "Current directory"))
// Check /tmp write permissions
results = append(results, t.checkWritePermission("/tmp", "Temporary directory"))
return results
}
// checkWritePermission checks write permission for a directory
func (t *TroubleshootingGuide) checkWritePermission(path, description string) DiagnosticResult {
// Try to create a test file
testFile := filepath.Join(path, ".debian-bootc-image-builder-test")
err := os.WriteFile(testFile, []byte("test"), 0644)
if err != nil {
// Clean up if file was created
os.Remove(testFile)
return DiagnosticResult{
Check: fmt.Sprintf("Write Permission: %s", description),
Status: "❌ Denied",
Message: fmt.Sprintf("Cannot write to %s", path),
Details: fmt.Sprintf("Permission denied: %v", err),
Fix: fmt.Sprintf("Check permissions: ls -la %s", path),
Critical: true,
}
}
// Clean up test file
os.Remove(testFile)
return DiagnosticResult{
Check: fmt.Sprintf("Write Permission: %s", description),
Status: "✅ Allowed",
Message: fmt.Sprintf("Can write to %s", path),
Details: "Write permissions are sufficient",
Critical: false,
}
}
// checkNetworkConnectivity checks network connectivity for required services
func (t *TroubleshootingGuide) checkNetworkConnectivity() []DiagnosticResult {
var results []DiagnosticResult
// Check basic internet connectivity
results = append(results, t.checkConnectivity("8.8.8.8", "Internet connectivity"))
// Check Debian repository connectivity
results = append(results, t.checkConnectivity("deb.debian.org", "Debian repositories"))
return results
}
// checkConnectivity checks network connectivity to a host
func (t *TroubleshootingGuide) checkConnectivity(host, description string) DiagnosticResult {
cmd := exec.Command("ping", "-c", "1", "-W", "5", host)
err := cmd.Run()
if err != nil {
return DiagnosticResult{
Check: fmt.Sprintf("Network: %s", description),
Status: "❌ Failed",
Message: fmt.Sprintf("Cannot reach %s", host),
Details: "Network connectivity issue",
Fix: "Check network connection and firewall settings",
Critical: false, // Not critical for basic functionality
}
}
return DiagnosticResult{
Check: fmt.Sprintf("Network: %s", description),
Status: "✅ Connected",
Message: fmt.Sprintf("Can reach %s", host),
Details: "Network connectivity is working",
Critical: false,
}
}
// checkContainerRuntime checks container runtime availability
func (t *TroubleshootingGuide) checkContainerRuntime() []DiagnosticResult {
var results []DiagnosticResult
// Check if podman is running
cmd := exec.Command("podman", "version")
err := cmd.Run()
if err != nil {
results = append(results, DiagnosticResult{
Check: "Container Runtime",
Status: "❌ Failed",
Message: "Podman is not working properly",
Details: "Cannot execute podman version command",
Fix: "Check podman installation and configuration",
Critical: true,
})
} else {
results = append(results, DiagnosticResult{
Check: "Container Runtime",
Status: "✅ Working",
Message: "Podman is working properly",
Details: "Container runtime is functional",
Critical: false,
})
}
return results
}
// PrintDiagnostics prints diagnostic results
func (t *TroubleshootingGuide) PrintDiagnostics(results []DiagnosticResult) {
fmt.Println("🔍 System Diagnostics:")
fmt.Println()
criticalIssues := 0
warnings := 0
for _, result := range results {
fmt.Printf("%s %s: %s\n", result.Status, result.Check, result.Message)
if t.verbose && result.Details != "" {
fmt.Printf(" Details: %s\n", result.Details)
}
if result.Fix != "" {
fmt.Printf(" Fix: %s\n", result.Fix)
}
if result.Critical {
criticalIssues++
} else if strings.Contains(result.Status, "⚠️") {
warnings++
}
fmt.Println()
}
// Summary
if criticalIssues > 0 {
fmt.Printf("❌ Found %d critical issues that must be resolved\n", criticalIssues)
} else if warnings > 0 {
fmt.Printf("⚠️ Found %d warnings (non-critical)\n", warnings)
} else {
fmt.Println("✅ All diagnostics passed - system is ready")
}
}
// GetCommonSolutions returns common solutions for frequent issues
func (t *TroubleshootingGuide) GetCommonSolutions() map[string][]string {
return map[string][]string{
"apt-cache failed": {
"Ensure apt-cache is installed: sudo apt install apt",
"Update package lists: sudo apt update",
"Check repository configuration: cat /etc/apt/sources.list",
},
"permission denied": {
"Check file/directory permissions: ls -la",
"Use appropriate user permissions",
"Try running with sudo if necessary",
},
"container not found": {
"Verify container image exists: podman images",
"Pull the container: podman pull <image>",
"Check image reference format",
},
"out of disk space": {
"Free up disk space: df -h",
"Remove unnecessary files",
"Use a different output directory",
},
"out of memory": {
"Close other applications",
"Add swap space if needed",
"Use a system with more RAM",
},
}
}

View file

@ -0,0 +1,323 @@
package ux
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Validator provides input validation functionality
type Validator struct {
verbose bool
}
// NewValidator creates a new validator
func NewValidator(verbose bool) *Validator {
return &Validator{verbose: verbose}
}
// ValidationResult represents the result of a validation
type ValidationResult struct {
Valid bool
Message string
Suggestions []string
}
// ValidateImageReference validates a container image reference
func (v *Validator) ValidateImageReference(imageRef string) *ValidationResult {
if imageRef == "" {
return &ValidationResult{
Valid: false,
Message: "Image reference cannot be empty",
Suggestions: []string{
"Provide a valid container image reference",
"Example: git.raines.xyz/particle-os/debian-bootc:latest",
"Example: docker.io/debian:bookworm-slim",
},
}
}
// Basic validation for image reference format
// Should contain at least one colon or slash
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "/") {
return &ValidationResult{
Valid: false,
Message: "Invalid image reference format",
Suggestions: []string{
"Image reference should contain registry, namespace, and tag",
"Example: registry.example.com/namespace/image:tag",
"Example: image:tag",
},
}
}
// Check for valid characters (allow hyphens in addition to other valid chars)
validChars := regexp.MustCompile(`^[a-zA-Z0-9._/:+-]+$`)
if !validChars.MatchString(imageRef) {
return &ValidationResult{
Valid: false,
Message: "Image reference contains invalid characters",
Suggestions: []string{
"Use only alphanumeric characters, dots, underscores, slashes, hyphens, and colons",
"Example: git.raines.xyz/particle-os/debian-bootc:latest",
},
}
}
return &ValidationResult{Valid: true}
}
// ValidateImageTypes validates image type specifications
func (v *Validator) ValidateImageTypes(imageTypes []string) *ValidationResult {
if len(imageTypes) == 0 {
return &ValidationResult{
Valid: false,
Message: "At least one image type must be specified",
Suggestions: []string{
"Specify one or more image types: qcow2, ami, vmdk, raw, debian-installer, calamares",
"Example: --type qcow2 --type ami",
},
}
}
validTypes := map[string]bool{
"qcow2": true,
"ami": true,
"vmdk": true,
"raw": true,
"debian-installer": true,
"calamares": true,
}
var invalidTypes []string
for _, imgType := range imageTypes {
if !validTypes[imgType] {
invalidTypes = append(invalidTypes, imgType)
}
}
if len(invalidTypes) > 0 {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Invalid image types: %s", strings.Join(invalidTypes, ", ")),
Suggestions: []string{
"Supported image types: qcow2, ami, vmdk, raw, debian-installer, calamares",
"Check spelling and case sensitivity",
},
}
}
return &ValidationResult{Valid: true}
}
// ValidateArchitecture validates target architecture
func (v *Validator) ValidateArchitecture(arch string) *ValidationResult {
if arch == "" {
return &ValidationResult{
Valid: false,
Message: "Target architecture cannot be empty",
Suggestions: []string{
"Specify a valid architecture: amd64, arm64, armhf, ppc64el, s390x",
"Example: --target-arch amd64",
},
}
}
validArchs := map[string]bool{
"amd64": true,
"arm64": true,
"armhf": true,
"ppc64el": true,
"s390x": true,
}
if !validArchs[arch] {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Unsupported architecture: %s", arch),
Suggestions: []string{
"Supported architectures: amd64, arm64, armhf, ppc64el, s390x",
"Use --target-arch amd64 for x86_64 systems",
},
}
}
return &ValidationResult{Valid: true}
}
// ValidateRootfsType validates root filesystem type
func (v *Validator) ValidateRootfsType(rootfsType string) *ValidationResult {
if rootfsType == "" {
// Empty is valid (will use default)
return &ValidationResult{Valid: true}
}
validTypes := map[string]bool{
"ext4": true,
"xfs": true,
"btrfs": true,
}
if !validTypes[rootfsType] {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Unsupported root filesystem type: %s", rootfsType),
Suggestions: []string{
"Supported filesystem types: ext4, xfs, btrfs",
"Leave empty to use default (ext4)",
},
}
}
return &ValidationResult{Valid: true}
}
// ValidateDirectory validates a directory path
func (v *Validator) ValidateDirectory(path, purpose string) *ValidationResult {
if path == "" {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("%s path cannot be empty", purpose),
Suggestions: []string{
fmt.Sprintf("Specify a valid directory path for %s", purpose),
"Use absolute or relative paths",
},
}
}
// Check if path exists and is a directory
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// Try to create the directory
if err := os.MkdirAll(path, 0755); err != nil {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Cannot create %s directory: %v", purpose, err),
Suggestions: []string{
"Check parent directory permissions",
"Use a different directory path",
"Run with appropriate permissions",
},
}
}
return &ValidationResult{Valid: true}
}
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Cannot access %s directory: %v", purpose, err),
Suggestions: []string{
"Check directory permissions",
"Ensure the path is accessible",
},
}
}
if !info.IsDir() {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("%s path is not a directory: %s", purpose, path),
Suggestions: []string{
"Specify a directory path, not a file",
"Check the path exists and is a directory",
},
}
}
return &ValidationResult{Valid: true}
}
// ValidateConfigFile validates a configuration file path
func (v *Validator) ValidateConfigFile(configPath string) *ValidationResult {
if configPath == "" {
// Empty is valid (will use default)
return &ValidationResult{Valid: true}
}
// Check if file exists
info, err := os.Stat(configPath)
if err != nil {
if os.IsNotExist(err) {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Configuration file not found: %s", configPath),
Suggestions: []string{
"Check the file path is correct",
"Create a configuration file at the specified path",
"Leave empty to use default configuration",
},
}
}
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Cannot access configuration file: %v", err),
Suggestions: []string{
"Check file permissions",
"Ensure the file is accessible",
},
}
}
if info.IsDir() {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Configuration path is a directory, not a file: %s", configPath),
Suggestions: []string{
"Specify a file path, not a directory",
"Example: .config/registry.yaml",
},
}
}
// Check file extension
ext := strings.ToLower(filepath.Ext(configPath))
if ext != ".yaml" && ext != ".yml" {
return &ValidationResult{
Valid: false,
Message: fmt.Sprintf("Configuration file should be YAML format: %s", configPath),
Suggestions: []string{
"Use .yaml or .yml extension",
"Example: .config/registry.yaml",
},
}
}
return &ValidationResult{Valid: true}
}
// ValidateDistroDefPath validates distribution definition path
func (v *Validator) ValidateDistroDefPath(path string) *ValidationResult {
if path == "" {
return &ValidationResult{
Valid: false,
Message: "Distribution definition path cannot be empty",
Suggestions: []string{
"Specify a valid directory path containing package definitions",
"Example: ./data/defs",
},
}
}
return v.ValidateDirectory(path, "distribution definition")
}
// PrintValidationResult prints validation results with suggestions
func (v *Validator) PrintValidationResult(result *ValidationResult) {
if result.Valid {
if v.verbose {
PrintInfo(os.Stdout, "Validation passed")
}
return
}
PrintError(os.Stdout, result.Message)
if len(result.Suggestions) > 0 {
fmt.Println("💡 Suggestions:")
for _, suggestion := range result.Suggestions {
fmt.Printf(" • %s\n", suggestion)
}
}
}