Skip to content

Commit 9d8406e

Browse files
authored
Merge branch 'main' into feature/ubuntu26-support
2 parents 48ae2d1 + 6a3d08f commit 9d8406e

31 files changed

+3538
-58
lines changed

.coverage-threshold

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
65.9
1+
66.2

docs/index.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
OS Image Composer is a command-line tool for building custom, bootable Linux
44
images from pre-built packages. Define your requirements in a YAML template,
5-
run one command to get a RAW image ready to deploy (ISO installers require an extra step; see the Installation Guide).
5+
run one command to get a RAW image ready for deployment (ISO installers require an extra step; see the Installation Guide).
66

77
**Supported distributions:** Azure Linux (azl3),
88
[Edge Microvisor Toolkit](https://docs.openedgeplatform.intel.com/2026.0/edge-microvisor-toolkit/index.html)
@@ -70,14 +70,15 @@ For build options (Earthly, Debian package) and prerequisite details, see the
7070
:::{toctree}
7171
:hidden:
7272
73-
Installation Guide <tutorial/installation.md>
74-
Prerequisites <tutorial/prerequisite.md>
75-
Architecture <architecture.md>
76-
Usage Guide <tutorial/usage-guide.md>
77-
Secure Boot Configuration <tutorial/configure-secure-boot.md>
78-
Configure Users <tutorial/configure-image-user.md>
79-
Customize Image Build <tutorial/configure-additional-actions-for-build.md>
80-
Configure Multiple Package Repositories <tutorial/configure-multiple-package-repositories.md>
73+
Installation Guide <./tutorial/installation.md>
74+
Prerequisites <./tutorial/prerequisite.md>
75+
Architecture <./architecture.md>
76+
Usage Guide <./tutorial/usage-guide.md>
77+
Secure Boot Configuration <./tutorial/configure-secure-boot.md>
78+
Configure Users <./tutorial/configure-image-user.md>
79+
Customize Image Build <./tutorial/configure-additional-actions-for-build.md>
80+
Configure Multiple Package Repositories <./tutorial/configure-multiple-package-repositories.md>
81+
AI Template Generation (RAG) <./tutorial/ai-template-generation.md>
8182
release-notes.md
8283
8384
:::

image-templates/emt3-x86_64-edge-raw.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ packageRepositories:
4141
pkey: "<PUBLIC_KEY_URL>" # Uncomment and replace in real config
4242
component: "restricted"
4343

44+
# - codename: "localrpm"
45+
# path: "/data/os-image-composer/localrpm" # Uncomment and replace with local path
46+
# pkey: "[trusted=yes]" # Make sure to put trusted
47+
# component: "main" # Make sure to put main or leave empty
48+
4449
# Disk configuration can be omitted to use defaults from default template
4550
# If specified, it will override the default disk configuration completely
4651

image-templates/ubuntu24-x86_64-edge-raw.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ packageRepositories:
4040
pkey: "<PUBLIC_KEY_URL>" # Uncomment and replace in real config
4141
component: "restricted"
4242

43+
# - codename: "localdeb"
44+
# path: "/data/os-image-composer/localdeb" # Uncomment and replace with local path
45+
# pkey: "[trusted=yes]" # Make sure to put trusted
46+
# component: "main" # Make sure to put main or leave empty
47+
4348
systemConfig:
4449
name: edge
4550
description: edge ubuntu image with immutable rootfs

image-templates/ubuntu24-x86_64-minimal-raw.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@ systemConfig:
8585
version: "6.17"
8686
cmdline: "console=ttyS0,115200 console=tty0 loglevel=7"
8787
packages:
88-
- linux-image-generic-hwe-24.04
88+
- linux-image-generic-hwe-24.04

internal/config/apt_sources_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,34 @@ func TestGenerateAptSourcesFromRepositories_NonDEBSystem(t *testing.T) {
387387
}
388388
}
389389

390+
func TestGenerateAptSourcesFromRepositories_PathOnlyRepo(t *testing.T) {
391+
template := &ImageTemplate{
392+
Target: TargetInfo{
393+
OS: "ubuntu",
394+
},
395+
PackageRepositories: []PackageRepository{
396+
{
397+
Codename: "localdeb",
398+
Path: "/data/os-image-composer/localdeb",
399+
PKey: "[trusted=yes]",
400+
Component: "main",
401+
},
402+
},
403+
SystemConfig: SystemConfig{
404+
AdditionalFiles: []AdditionalFileInfo{},
405+
},
406+
}
407+
408+
err := template.GenerateAptSourcesFromRepositories()
409+
if err != nil {
410+
t.Fatalf("GenerateAptSourcesFromRepositories() failed for path-only repo: %v", err)
411+
}
412+
413+
if len(template.SystemConfig.AdditionalFiles) != 0 {
414+
t.Errorf("expected no apt additional files for path-only repo, got %d", len(template.SystemConfig.AdditionalFiles))
415+
}
416+
}
417+
390418
func TestAddUniqueAdditionalFile(t *testing.T) {
391419
template := &ImageTemplate{
392420
SystemConfig: SystemConfig{
@@ -618,6 +646,13 @@ func TestNormalizeRepositoryPriorities(t *testing.T) {
618646
}
619647
}
620648

649+
func TestNormalizeRepositoryPriorities_NilInput(t *testing.T) {
650+
result := normalizeRepositoryPriorities(nil)
651+
if len(result) != 0 {
652+
t.Errorf("expected empty result for nil input, got len=%d", len(result))
653+
}
654+
}
655+
621656
func TestGetRepositoryName(t *testing.T) {
622657
tests := []struct {
623658
name string

internal/config/config.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ type DiskConfig struct {
4545
type PackageRepository struct {
4646
ID string `yaml:"id,omitempty"` // Auto-assigned
4747
Codename string `yaml:"codename"` // Repository identifier/codename
48-
URL string `yaml:"url"` // Repository base URL
48+
URL string `yaml:"url,omitempty"` // Repository base URL
49+
Path string `yaml:"path,omitempty"` // Local directory path for file-based repositories
4950
PKey string `yaml:"pkey"` // Public GPG key URL for verification
5051
PKeys []string `yaml:"pkeys,omitempty"` // Multiple public GPG key URLs for verification
5152
Component string `yaml:"component,omitempty"` // Repository component (e.g., "main", "restricted")
@@ -268,6 +269,10 @@ func parseYAMLTemplate(data []byte, validateFull bool) (*ImageTemplate, error) {
268269
return nil, fmt.Errorf("template parsing failed: invalid structure: %w", err)
269270
}
270271

272+
if err := template.validatePackageRepositories(); err != nil {
273+
return nil, err
274+
}
275+
271276
return &template, nil
272277
}
273278

@@ -949,3 +954,24 @@ func (i *ImmutabilityConfig) UnmarshalYAML(unmarshal func(interface{}) error) er
949954
func (i *ImmutabilityConfig) WasProvided() bool {
950955
return i.wasProvided
951956
}
957+
958+
func (t *ImageTemplate) validatePackageRepositories() error {
959+
for _, repo := range t.PackageRepositories {
960+
if err := repo.ValidatePackageRepository(); err != nil {
961+
return err
962+
}
963+
}
964+
965+
return nil
966+
}
967+
968+
// ValidatePackageRepository validates that either URL or Path is provided
969+
func (pr *PackageRepository) ValidatePackageRepository() error {
970+
if pr.URL == "" && pr.Path == "" {
971+
return fmt.Errorf("repository '%s': either 'url' or 'path' must be provided", pr.Codename)
972+
}
973+
if pr.URL != "" && pr.Path != "" {
974+
return fmt.Errorf("repository '%s': cannot specify both 'url' and 'path', choose one", pr.Codename)
975+
}
976+
return nil
977+
}

internal/config/config_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2429,6 +2429,55 @@ systemConfig:
24292429
}
24302430
}
24312431

2432+
func TestLoadTemplateRejectsInvalidPackageRepository(t *testing.T) {
2433+
yamlContent := `image:
2434+
name: test-invalid-repo
2435+
version: "1.0.0"
2436+
2437+
target:
2438+
os: azure-linux
2439+
dist: azl3
2440+
arch: x86_64
2441+
imageType: raw
2442+
2443+
packageRepositories:
2444+
- codename: "invalid-repo"
2445+
url: "https://example.com/repo"
2446+
path: "/tmp/repo"
2447+
pkey: "https://example.com/key.pub"
2448+
2449+
systemConfig:
2450+
name: test
2451+
packages:
2452+
- test-package
2453+
kernel:
2454+
version: "6.12"
2455+
cmdline: "quiet"
2456+
`
2457+
2458+
tmpFile, err := os.CreateTemp("", "test-invalid-repo-*.yml")
2459+
if err != nil {
2460+
t.Fatalf("failed to create temp file: %v", err)
2461+
}
2462+
if err := tmpFile.Chmod(0600); err != nil {
2463+
tmpFile.Close()
2464+
os.Remove(tmpFile.Name())
2465+
t.Fatalf("failed to set temp file permissions: %v", err)
2466+
}
2467+
defer os.Remove(tmpFile.Name())
2468+
2469+
if _, err := tmpFile.WriteString(yamlContent); err != nil {
2470+
tmpFile.Close()
2471+
t.Fatalf("failed to write temp file: %v", err)
2472+
}
2473+
tmpFile.Close()
2474+
2475+
_, err = LoadTemplate(tmpFile.Name(), false)
2476+
if err == nil {
2477+
t.Fatal("expected LoadTemplate to reject invalid package repository configuration")
2478+
}
2479+
}
2480+
24322481
func TestGlobalConfigSaveWithCreateDirectory(t *testing.T) {
24332482
config := &GlobalConfig{
24342483
Workers: 4,
@@ -3208,6 +3257,77 @@ systemConfig:
32083257
}
32093258
}
32103259

3260+
func TestPackageRepositoryYAMLParsingLocalPath(t *testing.T) {
3261+
yamlContent := `image:
3262+
name: test-local-repo-parsing
3263+
version: "1.0.0"
3264+
3265+
target:
3266+
os: ubuntu
3267+
dist: ubuntu24
3268+
arch: x86_64
3269+
imageType: raw
3270+
3271+
packageRepositories:
3272+
- codename: "localdeb"
3273+
path: "/data/os-image-composer/localdeb"
3274+
pkey: "[trusted=yes]"
3275+
component: "main"
3276+
3277+
systemConfig:
3278+
name: test
3279+
packages:
3280+
- test-package
3281+
kernel:
3282+
version: "6.12"
3283+
cmdline: "quiet"
3284+
`
3285+
3286+
tmpFile, err := os.CreateTemp("", "test-local-repo-*.yml")
3287+
if err != nil {
3288+
t.Fatalf("failed to create temp file: %v", err)
3289+
}
3290+
if err := tmpFile.Chmod(0600); err != nil {
3291+
tmpFile.Close()
3292+
os.Remove(tmpFile.Name())
3293+
return
3294+
}
3295+
defer os.Remove(tmpFile.Name())
3296+
3297+
if _, err := tmpFile.WriteString(yamlContent); err != nil {
3298+
t.Fatalf("failed to write temp file: %v", err)
3299+
}
3300+
tmpFile.Close()
3301+
3302+
template, err := LoadTemplate(tmpFile.Name(), false)
3303+
if err != nil {
3304+
t.Fatalf("failed to load YAML template with local package repository: %v", err)
3305+
}
3306+
3307+
repos := template.GetPackageRepositories()
3308+
if len(repos) != 1 {
3309+
t.Fatalf("expected 1 parsed repository, got %d", len(repos))
3310+
}
3311+
3312+
repo := template.GetRepositoryByCodename("localdeb")
3313+
if repo == nil {
3314+
t.Fatalf("expected to find localdeb repository")
3315+
}
3316+
3317+
if repo.Path != "/data/os-image-composer/localdeb" {
3318+
t.Errorf("expected repo path '/data/os-image-composer/localdeb', got '%s'", repo.Path)
3319+
}
3320+
if repo.PKey != "[trusted=yes]" {
3321+
t.Errorf("expected repo pkey '[trusted=yes]', got '%s'", repo.PKey)
3322+
}
3323+
if repo.Component != "main" {
3324+
t.Errorf("expected repo component 'main', got '%s'", repo.Component)
3325+
}
3326+
if repo.URL != "" {
3327+
t.Errorf("expected repo URL to be empty for local path repository, got '%s'", repo.URL)
3328+
}
3329+
}
3330+
32113331
func TestPackageRepositoriesWithDuplicateCodenames(t *testing.T) {
32123332
repos := []PackageRepository{
32133333
{Codename: "duplicate", URL: "https://first.com", PKey: "https://first.com/key.pub"},
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package schema
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestEmbeddedSchemasNonEmpty(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
schema []byte
12+
}{
13+
{"ImageTemplateSchema", ImageTemplateSchema},
14+
{"ConfigSchema", ConfigSchema},
15+
{"ChrootenvSchema", ChrootenvSchema},
16+
{"OsConfigSchema", OsConfigSchema},
17+
}
18+
19+
for _, tt := range tests {
20+
t.Run(tt.name, func(t *testing.T) {
21+
if len(tt.schema) == 0 {
22+
t.Fatalf("%s: expected non-empty bytes, got empty", tt.name)
23+
}
24+
})
25+
}
26+
}
27+
28+
func TestEmbeddedSchemasValidJSON(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
schema []byte
32+
}{
33+
{"ImageTemplateSchema", ImageTemplateSchema},
34+
{"ConfigSchema", ConfigSchema},
35+
{"ChrootenvSchema", ChrootenvSchema},
36+
{"OsConfigSchema", OsConfigSchema},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
var doc map[string]any
42+
if err := json.Unmarshal(tt.schema, &doc); err != nil {
43+
t.Fatalf("%s: invalid JSON: %v", tt.name, err)
44+
}
45+
})
46+
}
47+
}
48+
49+
func TestEmbeddedSchemasHaveMetadataFields(t *testing.T) {
50+
tests := []struct {
51+
name string
52+
schema []byte
53+
wantSchema string
54+
wantID string
55+
}{
56+
{
57+
name: "ImageTemplateSchema",
58+
schema: ImageTemplateSchema,
59+
wantSchema: "https://json-schema.org/draft/2020-12/schema",
60+
wantID: "os-image-template.schema.json",
61+
},
62+
{
63+
name: "ConfigSchema",
64+
schema: ConfigSchema,
65+
wantSchema: "https://json-schema.org/draft/2020-12/schema",
66+
wantID: "https://github.com/open-edge-platform/os-image-composer/schemas/os-image-composer-config.schema.json",
67+
},
68+
{
69+
name: "ChrootenvSchema",
70+
schema: ChrootenvSchema,
71+
wantSchema: "https://json-schema.org/draft/2020-12/schema",
72+
wantID: "chrootenv-config.schema.json",
73+
},
74+
{
75+
name: "OsConfigSchema",
76+
schema: OsConfigSchema,
77+
wantSchema: "https://json-schema.org/draft/2020-12/schema",
78+
wantID: "os-config.schema.json",
79+
},
80+
}
81+
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
var doc map[string]any
85+
if err := json.Unmarshal(tt.schema, &doc); err != nil {
86+
t.Fatalf("unexpected JSON parse error: %v", err)
87+
}
88+
89+
gotSchema, _ := doc["$schema"].(string)
90+
if gotSchema != tt.wantSchema {
91+
t.Errorf("$schema: got %q, want %q", gotSchema, tt.wantSchema)
92+
}
93+
94+
gotID, _ := doc["$id"].(string)
95+
if gotID != tt.wantID {
96+
t.Errorf("$id: got %q, want %q", gotID, tt.wantID)
97+
}
98+
})
99+
}
100+
}

0 commit comments

Comments
 (0)