├── .github ├── dependabot.yaml └── workflows │ ├── build_and_test.yml │ ├── build_image.yaml │ └── golangci-lint.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── RELEASE_NOTES.md ├── cmd └── eib │ └── main.go ├── config └── artifacts.yaml ├── docs ├── README.md ├── building-images.md ├── debugging.md ├── design │ ├── default-mounts-disablement.md │ ├── definition-schema.md │ ├── downloads.md │ └── pkg-resolution.md ├── images │ ├── general-1.png │ ├── libvirt-iso-1.png │ ├── libvirt-iso-10.png │ ├── libvirt-iso-11.png │ ├── libvirt-iso-2.png │ ├── libvirt-iso-3.png │ ├── libvirt-iso-4.png │ ├── libvirt-iso-5.png │ ├── libvirt-iso-6.png │ ├── libvirt-iso-7.png │ ├── libvirt-iso-8.png │ ├── libvirt-raw-1.png │ ├── libvirt-raw-2.png │ ├── rpm-eib-container-run.png │ └── rpm-resolver-architecture.png ├── installing-packages.md └── testing-guide.md ├── examples ├── README.md ├── iso │ ├── basic.yaml │ ├── k3s-single-node.yaml │ └── os-configuration.yaml └── raw │ ├── basic.yaml │ ├── k3s-single-node.yaml │ └── os-configuration.yaml ├── go.mod ├── go.sum └── pkg ├── build ├── build.go ├── build_test.go ├── grub.go ├── grub_test.go ├── iso.go ├── iso_test.go ├── raw.go ├── raw_test.go └── templates │ ├── extract-iso.sh.tpl │ ├── grub │ └── guestfish-snippet.tpl │ ├── modify-raw-image.sh.tpl │ └── rebuild-iso.sh.tpl ├── cache ├── cache.go └── cache_test.go ├── cli ├── build │ ├── build.go │ ├── validate.go │ └── version.go └── cmd │ ├── build.go │ ├── error.go │ ├── flags.go │ ├── root.go │ ├── validate.go │ └── version.go ├── combustion ├── certificates.go ├── certificates_test.go ├── cleanup.go ├── cleanup_test.go ├── combustion.go ├── combustion_test.go ├── custom.go ├── custom_test.go ├── elemental.go ├── elemental_test.go ├── fips.go ├── fips_test.go ├── groups.go ├── groups_test.go ├── helm.go ├── keymap.go ├── keymap_test.go ├── kubernetes.go ├── kubernetes_test.go ├── message.go ├── message_test.go ├── network.go ├── network_test.go ├── osfiles.go ├── osfiles_test.go ├── proxy.go ├── proxy_test.go ├── registry.go ├── registry_test.go ├── rpm.go ├── rpm_test.go ├── script.go ├── script_test.go ├── suma.go ├── suma_test.go ├── systemd.go ├── systemd_test.go ├── templates │ ├── 05-configure-network.sh.tpl │ ├── 07-certificates.sh.tpl │ ├── 08-proxy-setup.sh.tpl │ ├── 10-rpm-install.sh.tpl │ ├── 11-time-setup.sh.tpl │ ├── 12-keymap-setup.sh.tpl │ ├── 13a-add-groups.sh.tpl │ ├── 13b-add-users.sh.tpl │ ├── 14-systemd.sh.tpl │ ├── 15-fips-setup.sh │ ├── 19-copy-os-files.sh │ ├── 26-embedded-registry.sh.tpl │ ├── 30-suma-register.sh.tpl │ ├── 31-elemental-register.sh.tpl │ ├── 48-message.sh.tpl │ ├── cleanup-combustion.sh │ ├── k3s-multi-node-installer.sh.tpl │ ├── k3s-single-node-installer.sh.tpl │ ├── k8s-vip.yaml.tpl │ ├── registries.yaml.tpl │ ├── rke2-multi-node-installer.sh.tpl │ ├── rke2-single-node-installer.sh.tpl │ ├── script-base.sh.tpl │ └── set-node-ip.sh.tpl ├── time.go ├── time_test.go ├── users.go └── users_test.go ├── container ├── image_digester.go └── image_digester_test.go ├── eib ├── eib.go └── eib_test.go ├── fileio ├── file_io.go ├── file_io_test.go └── testdata │ └── copy-files │ ├── gpg.gpg │ ├── rpm.rpm │ ├── sub1-copy-files │ ├── dummy.txt │ ├── gpg.gpg │ └── rpm.rpm │ └── unwritable.txt ├── helm ├── helm.go └── helm_test.go ├── http ├── download.go ├── download_integration_test.go └── download_test.go ├── image ├── context.go ├── definition.go ├── definition_test.go ├── testdata │ └── full-valid-example.yaml └── validation │ ├── elemental.go │ ├── elemental_test.go │ ├── image.go │ ├── image_test.go │ ├── kubernetes.go │ ├── kubernetes_test.go │ ├── os.go │ ├── os_test.go │ ├── registry.go │ ├── registry_test.go │ ├── validation.go │ ├── validation_test.go │ ├── version.go │ └── version_test.go ├── kubernetes ├── artefact_downloader.go ├── artefact_downloader_test.go ├── cluster.go ├── cluster_test.go ├── cni.go ├── cni_test.go ├── install_script_downloader.go ├── selinux.go └── testdata │ ├── default │ ├── agent.yaml │ └── server.yaml │ ├── dualstack-prio-ipv4 │ ├── agent.yaml │ └── server.yaml │ └── dualstack-prio-ipv6 │ ├── agent.yaml │ └── server.yaml ├── log ├── audit.go ├── audit_test.go └── log.go ├── mount ├── mount.go └── mount_test.go ├── network ├── config_generator.go ├── config_generator_test.go ├── configurator_installer.go └── configurator_installer_test.go ├── podman ├── listener.go └── podman.go ├── registry ├── helm.go ├── helm_crd.go ├── helm_test.go ├── manifests.go ├── manifests_integration_test.go ├── manifests_test.go ├── registry.go ├── registry_test.go └── testdata │ ├── empty-crd.yaml │ ├── invalid-crd.yml │ └── sample-crd.yaml ├── rpm ├── repo.go └── resolver │ ├── base.go │ ├── resolver.go │ └── templates │ ├── Dockerfile.tpl │ ├── prepare-tarball.sh.tpl │ └── rpm-resolution.sh.tpl ├── template ├── template.go └── template_test.go └── version └── version.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | labels: 7 | - "kind/dependabot" 8 | schedule: 9 | interval: "weekly" 10 | - package-ecosystem: "gomod" 11 | directory: "/" 12 | labels: 13 | - "kind/dependabot" 14 | schedule: 15 | interval: "weekly" 16 | - package-ecosystem: "docker" 17 | directory: "/" 18 | labels: 19 | - "kind/dependabot" 20 | schedule: 21 | interval: "weekly" 22 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | on: [ push, pull_request ] 2 | name: Build & Test 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Set up Go 10 | uses: actions/setup-go@v5 11 | with: 12 | go-version: '1.24' 13 | - name: Install dependency libraries 14 | run: | 15 | sudo apt-get update 16 | sudo apt-get install -y libgpgme-dev libdevmapper-dev build-essential pkg-config libbtrfs-dev 17 | - name: Build 18 | run: go build -v ./... 19 | - name: Test 20 | run: go test -v ./... -tags integration 21 | -------------------------------------------------------------------------------- /.github/workflows/build_image.yaml: -------------------------------------------------------------------------------- 1 | on: [ push, pull_request ] 2 | name: Build Image 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Set up QEMU 10 | uses: docker/setup-qemu-action@v3 11 | - name: Set up Docker Buildx 12 | uses: docker/setup-buildx-action@v3 13 | - name: Build the image 14 | run: docker build -t eib:dev . 15 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | on: [ push, pull_request ] 2 | name: Lint 3 | 4 | permissions: 5 | contents: read 6 | pull-requests: read 7 | 8 | jobs: 9 | golangci: 10 | name: lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version: '1.24' 17 | cache: false 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v7 20 | with: 21 | version: v2.0 22 | only-new-issues: true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # IDE 21 | .idea -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | build-tags: 4 | - containers_image_openpgp 5 | - exclude_graphdriver_btrfs 6 | - exclude_graphdriver_devicemapper 7 | linters: 8 | default: none 9 | enable: 10 | - errcheck 11 | - errorlint 12 | - goconst 13 | - gocritic 14 | - gocyclo 15 | - gosec 16 | - govet 17 | - ineffassign 18 | - revive 19 | - staticcheck 20 | - unparam 21 | - unused 22 | settings: 23 | gocritic: 24 | disabled-checks: 25 | - whyNoLint 26 | - paramTypeCombine 27 | enabled-tags: 28 | - diagnostic 29 | - style 30 | - performance 31 | gocyclo: 32 | min-complexity: 15 33 | govet: 34 | settings: 35 | printf: 36 | funcs: 37 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 38 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 39 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 40 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 41 | exclusions: 42 | generated: lax 43 | presets: 44 | - comments 45 | - common-false-positives 46 | - legacy 47 | - std-error-handling 48 | paths: 49 | - third_party$ 50 | - builtin$ 51 | - examples$ 52 | formatters: 53 | enable: 54 | - gofmt 55 | - goimports 56 | settings: 57 | gofmt: 58 | rewrite-rules: 59 | - pattern: interface{} 60 | replacement: any 61 | exclusions: 62 | generated: lax 63 | paths: 64 | - third_party$ 65 | - builtin$ 66 | - examples$ 67 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ----- EIB Builder Image ----- 2 | FROM registry.suse.com/bci/golang:1.24.3-1.37.10 3 | 4 | # Dependency uses by line 5 | # 1. Podman Go library 6 | RUN zypper install -y \ 7 | gpgme-devel device-mapper-devel libbtrfs-devel 8 | 9 | WORKDIR /src 10 | 11 | COPY go.mod go.sum ./ 12 | COPY ./cmd ./cmd 13 | COPY ./pkg ./pkg 14 | COPY .git .git 15 | 16 | RUN --mount=type=cache,id=gomod,target=/go/pkg/mod \ 17 | --mount=type=cache,id=gobuild,target=/root/.cache/go-build \ 18 | go build ./cmd/eib 19 | 20 | # ----- Deliverable Image ----- 21 | FROM opensuse/leap:15.6 22 | 23 | # Dependency uses by line 24 | # 1. ISO image building 25 | # 2. RAW image modification on x86_64 and aarch64 26 | # 3. Podman EIB library 27 | # 4. RPM resolution logic 28 | # 5. Embedded artefact registry 29 | # 6. Network configuration 30 | # 7. SUSE registry certificates 31 | RUN zypper addrepo https://download.opensuse.org/repositories/isv:/SUSE:/Edge:/Factory/standard/isv:SUSE:Edge:Factory.repo && \ 32 | zypper addrepo https://download.opensuse.org/repositories/SUSE:CA/15.6/SUSE:CA.repo && \ 33 | zypper --gpg-auto-import-keys refresh && \ 34 | zypper install -y \ 35 | xorriso squashfs \ 36 | libguestfs kernel-default e2fsprogs parted gptfdisk btrfsprogs guestfs-tools lvm2 qemu-uefi-aarch64 \ 37 | podman \ 38 | createrepo_c \ 39 | helm hauler \ 40 | nm-configurator \ 41 | ca-certificates-suse && \ 42 | zypper clean -a 43 | 44 | # Make adjustments for running guestfish and image modifications on aarch64 45 | # guestfish looks for very specific locations on the filesystem for UEFI firmware 46 | # and also expects the boot kernel to be a portable executable (PE), not ELF. 47 | RUN mkdir -p /usr/share/edk2/aarch64 && \ 48 | cp /usr/share/qemu/aavmf-aarch64-code.bin /usr/share/edk2/aarch64/QEMU_EFI-pflash.raw && \ 49 | cp /usr/share/qemu/aavmf-aarch64-vars.bin /usr/share/edk2/aarch64/vars-template-pflash.raw && \ 50 | mv /boot/vmlinux* /boot/backup-vmlinux 51 | 52 | COPY --from=0 /src/eib /bin/eib 53 | COPY config/artifacts.yaml artifacts.yaml 54 | 55 | # Test eib executable to verify glibc compatibility on openSUSE Leap 56 | RUN /bin/eib version 57 | 58 | ENTRYPOINT ["/bin/eib"] 59 | -------------------------------------------------------------------------------- /cmd/eib/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/suse-edge/edge-image-builder/pkg/cli/build" 8 | "github.com/suse-edge/edge-image-builder/pkg/cli/cmd" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func main() { 13 | app := cmd.NewApp() 14 | app.Commands = []*cli.Command{ 15 | cmd.NewBuildCommand(build.Run), 16 | cmd.NewValidateCommand(build.Validate), 17 | cmd.NewVersionCommand(build.Version), 18 | } 19 | 20 | if err := app.Run(os.Args); err != nil { 21 | log.Fatal(err) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /config/artifacts.yaml: -------------------------------------------------------------------------------- 1 | metallb: 2 | chart: metallb 3 | repository: https://suse-edge.github.io/charts 4 | version: 0.1.0+up0.14.9 5 | endpoint-copier-operator: 6 | chart: endpoint-copier-operator 7 | repository: https://suse-edge.github.io/charts 8 | version: 0.2.1 9 | kubernetes: 10 | k3s: 11 | selinuxPackage: k3s-selinux-1.6-1.slemicro.noarch 12 | selinuxRepository: https://rpm.rancher.io/k3s/stable/common/slemicro/noarch 13 | rke2: 14 | selinuxPackage: rke2-selinux 15 | selinuxRepository: https://rpm.rancher.io/rke2/stable/common/slemicro/noarch -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Edge Image Builder User Guide 2 | 3 | ## Building Images 4 | 5 | The [Building Images Guide](./building-images.md) describes all of the possible customizations 6 | Edge Image Builder (EIB) can apply to an image. This guide describes the necessary image definition 7 | sections and image configuration directory structure for each configurable component and should serve 8 | as a starting guide to new EIB users. 9 | 10 | ## Installing Packages in an Image 11 | 12 | EIB provides the ability to define RPM repositories and indicate a list of packages to install on the built 13 | image. The RPMs and their dependencies will be embedded in the resulting image and, on first boot, will 14 | be installed on the running system. The [Installing Packages Guide](./installing-packages.md) contains 15 | detailed information on how to configure an image definition to support this functionality. 16 | 17 | ## Testing Images 18 | 19 | The [Testing Guide](./testing-guide.md) provides information on how to run the customized, ready to boot (CRB) 20 | images produced by EIB in a virtual machine. 21 | 22 | ## Debugging 23 | 24 | The [Debugging Guide](./debugging.md) describes the log files generated during a build and where to look 25 | in the event of a failed build. This guide also contains information on the build directory generated when 26 | EIB runs and the files it may contain. -------------------------------------------------------------------------------- /docs/design/definition-schema.md: -------------------------------------------------------------------------------- 1 | # Image Definition Schema 2 | 3 | ## Schema Fields 4 | 5 | TBD 6 | 7 | ## Versioning 8 | 9 | There are three types of changes that may be made to the definition schema between releases: 10 | * New optional additions 11 | * New required additions that would break existing definitions 12 | * Changes to existing fields (renaming, changing types, removing a field) 13 | 14 | The definition schema versioning will adhere to the following rules: 15 | 16 | **1. Each time a new release comes out that requires a schema change (regardless of which type of change occurred), 17 | the schema version will be bumped to match the EIB version.** 18 | 19 | For example, if the latest version of the schema is 1.1, and the schema is not changed until the 1.4 release, the next 20 | (or current, at the time) schema version will be 1.4. 21 | 22 | **2. Within a major release (i.e. 2.x, 3.x), all minor releases will be backward compatible with all schema versions 23 | since the x.0 release in that stream.** 24 | 25 | **3. Breaking changes, such as new required fields or changes to existing fields, may only be done at the start of 26 | a new major release (x.0)** 27 | 28 | For example, assume the following releases have occurred. For clarity, the schema versions will be letters in this 29 | example. The following describes the schema version compatibility: 30 | 31 | | EIB Version | Supported Schema Versions | Notes | 32 | |-------------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------| 33 | | 1.0 | a | When EIB 1.0 is released, the schema version is 'a' | 34 | | 1.1 | a | EIB 1.1 does not make any changes to the schema | 35 | | 1.2 | a, b | EIB 1.2 introduces non-breaking schema changes, making the current version 'b'. Schema version 'a' is still supported. | 36 | | 1.3 | a, b, c | EIB 1.3 also introduces non-breaking schema changes, bumping the version to 'c' while supporting all versions in the 1.x stream | 37 | | 2.0 | d | At the next major EIB release, a new schema version number is used, dropping backward compatibility with the 1.x versions | 38 | | 2.1 | d, e | The pattern continues throughout the 2.x stream | 39 | 40 | Keeping in mind the first rule, the values for the letters in the above table are as follows: 41 | 42 | | Key | Schema Version | 43 | |-----|----------------| 44 | | a | 1.0 | 45 | | b | 1.2 | 46 | | c | 1.3 | 47 | | d | 2.0 | 48 | | e | 2.1 | 49 | 50 | For each new schema version, EIB will provide a migration path to ease the transition (likely documentation, however 51 | where applicable a small script may be provided if possible). -------------------------------------------------------------------------------- /docs/design/downloads.md: -------------------------------------------------------------------------------- 1 | # Edge Image Builder Downloads 2 | 3 | ## EIB Builder Image Container Downloads 4 | 5 | These packages are necessary for building the EIB binary. 6 | ``` 7 | gpgme-devel 8 | device-mapper-devel 9 | libbtrfs-devel 10 | ``` 11 | 12 | ## EIB Deliverable Image Container Downloads 13 | 14 | These packages are used by EIB to build the RTD image. Some of these package binaries may be copied to the RTD image. 15 | 16 | Repository 17 | ``` 18 | https://download.opensuse.org/repositories/isv:SUSE:Edge:EdgeImageBuilder/SLE-15-SP5/isv:SUSE:Edge:EdgeImageBuilder.repo 19 | ``` 20 | Packages 21 | ``` 22 | xorriso 23 | squashfs 24 | libguestfs 25 | kernel-default 26 | e2fsprogs 27 | parted 28 | gptfdisk 29 | btrfsprogs 30 | guestfs-tools 31 | lvm2 32 | podman 33 | createrepo_c 34 | helm 35 | hauler 36 | nm-configurator 37 | ``` 38 | 39 | ## EIB Deliverable Image Programmatic Downloads 40 | 41 | These artifacts are programmatically downloaded by EIB during build time. Some of these artifacts may be copied to the RTD image. 42 | 43 | ### Elemental 44 | Source 45 | ``` 46 | https://download.opensuse.org/repositories/isv:/Rancher:/Elemental:/Maintenance:/5.5/standard/ 47 | ``` 48 | Packages 49 | ``` 50 | elemental-register 51 | elemental-system-agent 52 | ``` 53 | 54 | ### RKE2/K3s 55 | #### Releases 56 | 57 | Sources 58 | ``` 59 | https://github.com/rancher/rke2/releases/download/ 60 | https://github.com/k3s-io/k3s/releases/download/ 61 | ``` 62 | Artifacts 63 | ``` 64 | RKE2 Binary 65 | RKE2 Core Images 66 | RKE2 Checksums 67 | RKE2 Calico Images 68 | RKE2 Canal Images 69 | RKE2 Cilium Images 70 | RKE2 Multus Images 71 | 72 | K3s Binary 73 | K3s Images 74 | ``` 75 | 76 | #### Installation Script 77 | 78 | Sources 79 | ``` 80 | https://get.rke2.io 81 | https://get.k3s.io 82 | ``` 83 | Artifacts 84 | ``` 85 | RKE2 Install Script 86 | K3s Install Script 87 | ``` 88 | 89 | #### SELinux 90 | 91 | Sources 92 | ``` 93 | https://rpm.rancher.io/k3s/stable/common/slemicro/noarch 94 | https://rpm.rancher.io/rke2/stable/common/slemicro/noarch 95 | https://rpm.rancher.io/ 96 | ``` 97 | Artifacts 98 | ``` 99 | public.key 100 | rke2-selinux 101 | k3s-selinux 102 | ``` 103 | -------------------------------------------------------------------------------- /docs/images/general-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/general-1.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-1.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-10.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-11.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-2.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-3.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-4.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-5.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-6.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-7.png -------------------------------------------------------------------------------- /docs/images/libvirt-iso-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-iso-8.png -------------------------------------------------------------------------------- /docs/images/libvirt-raw-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-raw-1.png -------------------------------------------------------------------------------- /docs/images/libvirt-raw-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/libvirt-raw-2.png -------------------------------------------------------------------------------- /docs/images/rpm-eib-container-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/rpm-eib-container-run.png -------------------------------------------------------------------------------- /docs/images/rpm-resolver-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/docs/images/rpm-resolver-architecture.png -------------------------------------------------------------------------------- /examples/iso/basic.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1.2 2 | image: 3 | arch: x86_64 4 | imageType: iso 5 | baseImage: SL-Micro.x86_64-6.0-Default-SelfInstall-GM2.install.iso 6 | outputImageName: ./out/basic.iso 7 | operatingSystem: 8 | isoConfiguration: 9 | installDevice: /dev/vda 10 | users: 11 | - username: root 12 | encryptedPassword: $6$r2bo4ZUwh6Tnhi61$rTJJWWAaDB3Hk/NAkdJgFH26eJSE8NAIL/HjpO7Lunm0hQKNhvnKGEvoWMjduOIZKi4cB5KbOhEQZNjguLcMR/ 13 | -------------------------------------------------------------------------------- /examples/iso/k3s-single-node.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1.2 2 | image: 3 | arch: x86_64 4 | imageType: iso 5 | baseImage: SL-Micro.x86_64-6.0-Default-SelfInstall-GM2.install.iso 6 | outputImageName: single-node-k3s-no-selinux.iso 7 | kubernetes: 8 | version: v1.30.3+k3s1 9 | network: 10 | apiVIP: 192.168.100.19 11 | manifests: 12 | urls: 13 | - https://k8s.io/examples/application/nginx-app.yaml 14 | operatingSystem: 15 | isoConfiguration: 16 | installDevice: /dev/vda 17 | users: 18 | - username: root 19 | createHomeDir: true 20 | encryptedPassword: $6$jHugJNNd3HElGsUZ$eodjVe4te5ps44SVcWshdfWizrP.xAyd71CVEXazBJ/.v799/WRCBXxfYmunlBO2yp1hm/zb4r8EmnrrNCF.P/ 21 | sshKeys: 22 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAkfuT5nRHeb6EbbA+fdkt/d4ITDSWrVJiLtZnJdNw+x eib-testing 23 | -------------------------------------------------------------------------------- /examples/iso/os-configuration.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1.2 2 | image: 3 | arch: x86_64 4 | imageType: iso 5 | baseImage: SL-Micro.x86_64-6.0-Default-SelfInstall-GM2.install.iso 6 | outputImageName: iso-image.iso 7 | operatingSystem: 8 | isoConfiguration: 9 | installDevice: /dev/vda 10 | time: 11 | timezone: US/Eastern 12 | ntp: 13 | forceWait: true 14 | pools: 15 | - north-america.pool.ntp.org 16 | servers: 17 | - 0.north-america.pool.ntp.org 18 | keymap: us 19 | users: 20 | - username: root 21 | encryptedPassword: $6$jHugJNNd3HElGsUZ$eodjVe4te5ps44SVcWshdfWizrP.xAyd71CVEXazBJ/.v799/WRCBXxfYmunlBO2yp1hm/zb4r8EmnrrNCF.P/ 22 | - username: alpha 23 | uid: 2000 24 | encryptedPassword: $6$bZfTI3Wj05fdxQcB$W1HJQTKw/MaGTCwK75ic9putEquJvYO7vMnDBVAfuAMFW58/79abky4mx9.8znK0UZwSKng9dVosnYQR1toH71 25 | sshKeys: 26 | - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain 27 | - ssh-rsa BBBBB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain 28 | createHomeDir: true 29 | primaryGroup: group1 30 | secondaryGroups: 31 | - group2 32 | - username: beta 33 | encryptedPassword: $6$GHjiVHm2AT.Qxznz$1CwDuEBM1546E/sVE1Gn1y4JoGzW58wrckyx3jj2QnphFmceS6b/qFtkjw1cp7LSJNW1OcLe/EeIxDDHqZU6o1 34 | createHomeDir: false 35 | - username: gamma 36 | createHomeDir: true 37 | sshKeys: 38 | - ssh-rsa BBBBB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain 39 | groups: 40 | - name: group0 41 | - name: group1 42 | gid: 1234 43 | - name: group2 44 | kernelArgs: 45 | - fips=1 46 | - alpha=foo 47 | - beta=bar 48 | - baz 49 | systemd: 50 | disable: 51 | - rebootmgr 52 | enable: 53 | - rsyncd -------------------------------------------------------------------------------- /examples/raw/basic.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1.2 2 | image: 3 | arch: x86_64 4 | imageType: raw 5 | baseImage: SL-Micro.x86_64-6.0-Default-GM2.raw 6 | outputImageName: ./out/basic.raw 7 | operatingSystem: 8 | users: 9 | - username: root 10 | encryptedPassword: $6$r2bo4ZUwh6Tnhi61$rTJJWWAaDB3Hk/NAkdJgFH26eJSE8NAIL/HjpO7Lunm0hQKNhvnKGEvoWMjduOIZKi4cB5KbOhEQZNjguLcMR/ 11 | -------------------------------------------------------------------------------- /examples/raw/k3s-single-node.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1.2 2 | image: 3 | arch: x86_64 4 | imageType: raw 5 | baseImage: SL-Micro.x86_64-6.0-Default-GM2.raw 6 | outputImageName: single-node-k3s-no-selinux.raw 7 | kubernetes: 8 | version: v1.30.3+k3s1 9 | network: 10 | apiVIP: 192.168.100.19 11 | manifests: 12 | urls: 13 | - https://k8s.io/examples/application/nginx-app.yaml 14 | operatingSystem: 15 | rawConfiguration: 16 | diskSize: 30G 17 | users: 18 | - username: root 19 | createHomeDir: true 20 | encryptedPassword: $6$jHugJNNd3HElGsUZ$eodjVe4te5ps44SVcWshdfWizrP.xAyd71CVEXazBJ/.v799/WRCBXxfYmunlBO2yp1hm/zb4r8EmnrrNCF.P/ 21 | sshKeys: 22 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAkfuT5nRHeb6EbbA+fdkt/d4ITDSWrVJiLtZnJdNw+x eib-testing 23 | -------------------------------------------------------------------------------- /examples/raw/os-configuration.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1.2 2 | image: 3 | arch: x86_64 4 | imageType: raw 5 | baseImage: SL-Micro.x86_64-6.0-Default-GM2.raw 6 | outputImageName: raw-image.raw 7 | operatingSystem: 8 | rawConfiguration: 9 | diskSize: 32G 10 | time: 11 | timezone: US/Eastern 12 | ntp: 13 | forceWait: true 14 | pools: 15 | - north-america.pool.ntp.org 16 | servers: 17 | - 0.north-america.pool.ntp.org 18 | keymap: us 19 | users: 20 | - username: root 21 | encryptedPassword: $6$jHugJNNd3HElGsUZ$eodjVe4te5ps44SVcWshdfWizrP.xAyd71CVEXazBJ/.v799/WRCBXxfYmunlBO2yp1hm/zb4r8EmnrrNCF.P/ 22 | - username: alpha 23 | uid: 2000 24 | encryptedPassword: $6$bZfTI3Wj05fdxQcB$W1HJQTKw/MaGTCwK75ic9putEquJvYO7vMnDBVAfuAMFW58/79abky4mx9.8znK0UZwSKng9dVosnYQR1toH71 25 | sshKeys: 26 | - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain 27 | - ssh-rsa BBBBB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain 28 | createHomeDir: true 29 | primaryGroup: group1 30 | secondaryGroups: 31 | - group2 32 | - username: beta 33 | encryptedPassword: $6$GHjiVHm2AT.Qxznz$1CwDuEBM1546E/sVE1Gn1y4JoGzW58wrckyx3jj2QnphFmceS6b/qFtkjw1cp7LSJNW1OcLe/EeIxDDHqZU6o1 34 | createHomeDir: false 35 | - username: gamma 36 | createHomeDir: true 37 | sshKeys: 38 | - ssh-rsa BBBBB3NzaC1yc2EAAAADAQABAAABgQDnb80jkq8jYqC7EeXdtmdMLoQ/qeCzFPRrNyA5H5iB3k21Oc8ccBR2nIbteam39E0p4mwR2MVNACOR0cixgWskIb5bR8KqiqLMdj4PKMLX5r1jbtcB3/6beBKPqOpk0N2NwTy5BUH8NMwRpdzcq0QeY60f1z+PLJ4vTb0mcdyRkO4m0mqGa/LrBn9H5V3AAW6TdLO9LKjvUqHX+6vWKiWu2wJffTQQAxY9rsT+JoBVk8zes06zh+CVd7bGozJXp1t6SHQjJ7V9pLNfdMO4TJFpi3mVh3RLsg24RGoMVRNCjfYaBQkUJununzpPB9O9esOhfffM2puumAkspPALMiODcYK5bzF26YvDM124e5VQJo50GqbTNJEXB7PsZF4TezivS5xCuGoO6sSrk+heWKzgnLK7/qHI55XuExBbzfTawwWpGrSOw4YYCkrCa0bPYsY8Ef5iIQMwFseWz0i57eZp2pJfn65p4osM+r08R+X8BwEvK+BsyW/wtCI06xwFtdM= root@localhost.localdomain 39 | groups: 40 | - name: group0 41 | - name: group1 42 | gid: 1234 43 | - name: group2 44 | kernelArgs: 45 | - fips=1 46 | - alpha=foo 47 | - beta=bar 48 | - baz 49 | systemd: 50 | disable: 51 | - rebootmgr 52 | enable: 53 | - rsyncd -------------------------------------------------------------------------------- /pkg/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/suse-edge/edge-image-builder/pkg/image" 9 | "github.com/suse-edge/edge-image-builder/pkg/log" 10 | ) 11 | 12 | type imageConfigurator interface { 13 | Configure(ctx *image.Context) error 14 | } 15 | 16 | type Builder struct { 17 | context *image.Context 18 | imageConfigurator imageConfigurator 19 | } 20 | 21 | func NewBuilder(ctx *image.Context, imageConfigurator imageConfigurator) *Builder { 22 | return &Builder{ 23 | context: ctx, 24 | imageConfigurator: imageConfigurator, 25 | } 26 | } 27 | 28 | func (b *Builder) Build() error { 29 | log.Audit("Generating image customization components...") 30 | 31 | if err := b.imageConfigurator.Configure(b.context); err != nil { 32 | log.Audit("Error configuring customization components.") 33 | return fmt.Errorf("configuring image: %w", err) 34 | } 35 | 36 | switch b.context.ImageDefinition.Image.ImageType { 37 | case image.TypeISO: 38 | log.Audit("Building ISO image...") 39 | if err := b.buildIsoImage(); err != nil { 40 | log.Audit("Error building ISO image.") 41 | return err 42 | } 43 | case image.TypeRAW: 44 | log.Audit("Building RAW image...") 45 | if err := b.buildRawImage(); err != nil { 46 | log.Audit("Error building RAW image.") 47 | return err 48 | } 49 | default: 50 | return fmt.Errorf("invalid imageType value specified, must be either \"%s\" or \"%s\"", 51 | image.TypeISO, image.TypeRAW) 52 | } 53 | 54 | log.Auditf("Build complete, the image can be found at: %s", 55 | b.context.ImageDefinition.Image.OutputImageName) 56 | return nil 57 | } 58 | 59 | func (b *Builder) generateBuildDirFilename(filename string) string { 60 | return filepath.Join(b.context.BuildDir, filename) 61 | } 62 | 63 | func (b *Builder) generateOutputImageFilename() string { 64 | filename := filepath.Join(b.context.ImageConfigDir, b.context.ImageDefinition.Image.OutputImageName) 65 | return filename 66 | } 67 | 68 | func (b *Builder) generateBaseImageFilename() string { 69 | filename := filepath.Join(b.context.ImageConfigDir, "base-images", b.context.ImageDefinition.Image.BaseImage) 70 | return filename 71 | } 72 | 73 | func (b *Builder) deleteExistingOutputImage() error { 74 | outputFilename := b.generateOutputImageFilename() 75 | err := os.Remove(outputFilename) 76 | if err != nil && !os.IsNotExist(err) { 77 | return fmt.Errorf("error deleting file %s: %w", outputFilename, err) 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/build/build_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | ) 12 | 13 | func TestGenerateBuildDirFilename(t *testing.T) { 14 | // Setup 15 | tmpDir, err := os.MkdirTemp("", "eib-") 16 | require.NoError(t, err) 17 | defer func() { 18 | assert.NoError(t, os.RemoveAll(tmpDir)) 19 | }() 20 | 21 | builder := Builder{ 22 | context: &image.Context{ 23 | BuildDir: tmpDir, 24 | }, 25 | } 26 | 27 | testFilename := "build-dir-file.sh" 28 | 29 | // Test 30 | filename := builder.generateBuildDirFilename(testFilename) 31 | 32 | // Verify 33 | expectedFilename := filepath.Join(builder.context.BuildDir, testFilename) 34 | require.Equal(t, expectedFilename, filename) 35 | } 36 | 37 | func TestDeleteNoExistingImage(t *testing.T) { 38 | // Setup 39 | tmpDir, err := os.MkdirTemp("", "eib-") 40 | require.NoError(t, err) 41 | defer os.RemoveAll(tmpDir) 42 | 43 | builder := Builder{ 44 | context: &image.Context{ 45 | ImageConfigDir: tmpDir, 46 | ImageDefinition: &image.Definition{ 47 | Image: image.Image{ 48 | OutputImageName: "not-there", 49 | }, 50 | }, 51 | }, 52 | } 53 | 54 | // Test 55 | err = builder.deleteExistingOutputImage() 56 | 57 | // Verify 58 | require.NoError(t, err) 59 | } 60 | 61 | func TestDeleteExistingImage(t *testing.T) { 62 | // Setup 63 | tmpDir, err := os.MkdirTemp("", "eib-") 64 | require.NoError(t, err) 65 | defer os.RemoveAll(tmpDir) 66 | 67 | builder := Builder{ 68 | context: &image.Context{ 69 | ImageConfigDir: tmpDir, 70 | ImageDefinition: &image.Definition{ 71 | Image: image.Image{ 72 | OutputImageName: "not-there", 73 | }, 74 | }, 75 | }, 76 | } 77 | 78 | _, err = os.Create(builder.generateOutputImageFilename()) 79 | require.NoError(t, err) 80 | 81 | // Test 82 | err = builder.deleteExistingOutputImage() 83 | 84 | // Verify 85 | require.NoError(t, err) 86 | 87 | _, err = os.Stat(builder.generateOutputImageFilename()) 88 | require.Error(t, err) 89 | require.True(t, os.IsNotExist(err)) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/build/grub.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/suse-edge/edge-image-builder/pkg/log" 9 | "github.com/suse-edge/edge-image-builder/pkg/template" 10 | ) 11 | 12 | const ( 13 | kernelComponentName = "kernel params" 14 | ) 15 | 16 | //go:embed templates/grub/guestfish-snippet.tpl 17 | var guestfishSnippet string 18 | 19 | func (b *Builder) generateGRUBGuestfishCommands() (string, error) { 20 | // Nothing to do if there aren't any args. Return an empty string that will be injected 21 | // into the raw image guestfish modification, effectively doing nothing but not breaking 22 | // the guestfish command 23 | if b.context.ImageDefinition.OperatingSystem.KernelArgs == nil { 24 | log.AuditComponentSkipped(kernelComponentName) 25 | return "", nil 26 | } 27 | 28 | argLine := strings.Join(b.context.ImageDefinition.OperatingSystem.KernelArgs, " ") 29 | values := struct { 30 | KernelArgs string 31 | }{ 32 | KernelArgs: argLine, 33 | } 34 | 35 | snippet, err := template.Parse("guestfish-snippet", guestfishSnippet, values) 36 | if err != nil { 37 | log.AuditComponentFailed(kernelComponentName) 38 | return "", fmt.Errorf("parsing GRUB guestfish snippet: %w", err) 39 | } 40 | 41 | log.AuditComponentSuccessful(kernelComponentName) 42 | return snippet, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/build/grub_test.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "github.com/suse-edge/edge-image-builder/pkg/image" 9 | ) 10 | 11 | func TestGenerateGRUBGuestfishCommands(t *testing.T) { 12 | // Setup 13 | builder := Builder{ 14 | context: &image.Context{ 15 | ImageDefinition: &image.Definition{ 16 | OperatingSystem: image.OperatingSystem{ 17 | KernelArgs: []string{"alpha", "beta"}, 18 | }, 19 | }, 20 | }, 21 | } 22 | 23 | // Test 24 | commandString, err := builder.generateGRUBGuestfishCommands() 25 | 26 | // Verify 27 | require.NoError(t, err) 28 | require.NotNil(t, commandString) 29 | 30 | expectedDefault := "sed -i '/^GRUB_CMDLINE_LINUX_DEFAULT=\"/ s/\"$/ alpha beta \"/' /tmp/grub" 31 | assert.Contains(t, commandString, expectedDefault) 32 | } 33 | 34 | func TestGenerateGRUBGuestfishCommandsNoArgs(t *testing.T) { 35 | // Setup 36 | builder := Builder{ 37 | context: &image.Context{ 38 | ImageDefinition: &image.Definition{ 39 | OperatingSystem: image.OperatingSystem{}, 40 | }, 41 | }, 42 | } 43 | 44 | // Test 45 | commandString, err := builder.generateGRUBGuestfishCommands() 46 | 47 | // Verify 48 | require.NoError(t, err) 49 | assert.Equal(t, "", commandString) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/build/templates/extract-iso.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Template Fields 5 | # IsoExtractDir - Full path to the directory (under the build directory) where the ISO should be extracted 6 | # RawExtractDir - Full path to the directory (under the build directory) where the RAW image should be 7 | # unsquashed 8 | # IsoSource - Full path to the ISO to extract 9 | 10 | ISO_EXTRACT_DIR={{.IsoExtractDir}} 11 | RAW_EXTRACT_DIR={{.RawExtractDir}} 12 | ISO_SOURCE={{.IsoSource}} 13 | 14 | # Create the extract directories 15 | mkdir -p ${ISO_EXTRACT_DIR} 16 | 17 | # Extract the contents of the ISO to the build directory 18 | xorriso -osirrox on -indev ${ISO_SOURCE} extract / ${ISO_EXTRACT_DIR} 19 | 20 | # Unsquash the raw image 21 | SQUASHED_IMAGE_NAME=`find ${ISO_EXTRACT_DIR} -name "*.squashfs"` 22 | unsquashfs -d ${RAW_EXTRACT_DIR} ${SQUASHED_IMAGE_NAME} 23 | -------------------------------------------------------------------------------- /pkg/build/templates/grub/guestfish-snippet.tpl: -------------------------------------------------------------------------------- 1 | # Configure GRUB defaults 2 | # - So that the update below, and later`transactional-update grub.cfg` will persist the changes 3 | download /etc/default/grub /tmp/grub 4 | ! sed -i '/^GRUB_CMDLINE_LINUX_DEFAULT="/ s/"$/ {{.KernelArgs}} "/' /tmp/grub 5 | upload /tmp/grub /etc/default/grub 6 | 7 | # Configure GRUB for first boot 8 | # - This re-generates the grub.cfg applying the /etc/default/grub above 9 | sh "grub2-mkconfig -o /boot/grub2/grub.cfg" 10 | -------------------------------------------------------------------------------- /pkg/build/templates/modify-raw-image.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Template Fields 5 | # ImagePath - Full path to the image to modify 6 | # CombustionDir - Full path to the combustion directory 7 | # ArtefactsDir - Full path to the artefacts directory 8 | # ConfigureGRUB - Contains the guestfish command lines to run to manipulate GRUB configuration. 9 | # If there is no specific GRUB configuration to do, this will be an empty string. 10 | # ConfigureCombustion - If true, the combustion and artefacts directories will be included in the raw image 11 | # RenameFilesystem - If true, the filesystem of the image will be renamed (see below for information 12 | # on why this is needed) 13 | # Arch - The architecture of the image to be built 14 | # LUKSKey - The key necessary for modifying encrypted raw images 15 | # ExpandEncryptedPartition - If true, expands the encrypted partition during the build process 16 | # 17 | # Guestfish Command Documentation: https://libguestfs.org/guestfish.1.html 18 | 19 | # In x86_64, the default root partition is the third partition 20 | ROOT_PART=/dev/sda3 21 | 22 | # Make the necessary adaptations for aarch64 23 | if [[ {{ .Arch }} == "aarch64" ]]; then 24 | if ! test -f /dev/kvm; then 25 | export LIBGUESTFS_BACKEND_SETTINGS=force_tcg 26 | fi 27 | ROOT_PART=/dev/sda2 28 | fi 29 | 30 | # Set the LUKS key flag for encrypted images 31 | LUKSFLAG="" 32 | {{ if .LUKSKey }} 33 | LUKSFLAG="--key all:key:{{ .LUKSKey }}" 34 | {{ end }} 35 | 36 | # Test the block size of the base image and adapt to suit either 512/4096 byte images 37 | BLOCKSIZE=512 38 | if ! guestfish -i --blocksize=$BLOCKSIZE -a {{.ImagePath}} $LUKSFLAG echo "[INFO] 512 byte sector check successful."; then 39 | echo "[WARN] Failed to access image with 512 byte sector size, trying 4096 bytes." 40 | BLOCKSIZE=4096 41 | fi 42 | 43 | # Resize the raw disk image to accommodate the users desired raw disk image size 44 | # This is also required if embedding content into /combustion, especially for airgap. 45 | # Should *only* execute if the user is building a raw disk image. 46 | {{ if ne .DiskSize "" -}} 47 | truncate -r {{.ImagePath}} {{.ImagePath}}.expanded 48 | truncate -s {{.DiskSize}} {{.ImagePath}}.expanded 49 | virt-resize --expand $ROOT_PART {{.ImagePath}} {{.ImagePath}}.expanded 50 | cp {{.ImagePath}}.expanded {{.ImagePath}} 51 | rm -f {{.ImagePath}}.expanded 52 | {{ end }} 53 | 54 | guestfish --blocksize=$BLOCKSIZE --format=raw --rw -a {{.ImagePath}} $LUKSFLAG -i <<'EOF' 55 | # Enables write access to the read only filesystem 56 | sh "btrfs property set / ro false" 57 | 58 | {{ if .ExpandEncryptedPartition }} 59 | sh "btrfs filesystem resize max /" 60 | {{ end }} 61 | 62 | {{ if ne .ConfigureGRUB "" }} 63 | {{ .ConfigureGRUB }} 64 | {{ end }} 65 | 66 | {{ if .ConfigureCombustion }} 67 | copy-in {{.CombustionDir}} / 68 | copy-in {{.ArtefactsDir}} / 69 | {{ end }} 70 | 71 | {{ if .RenameFilesystem }} 72 | # As of Oct 25, 2023, combustion only checks volumes of certain names for the 73 | # /combustion directory. The SLE Micro raw image sets the root partition name to 74 | # "ROOT", which isn't one of the checked volume names. This line changes the 75 | # label to "INSTALL" (the same as the ISO installer uses) so it's picked up 76 | # when combustion runs. 77 | sh "btrfs filesystem label / INSTALL" 78 | {{ end }} 79 | 80 | # Resets the filesystem to read only 81 | sh "btrfs property set / ro true" 82 | EOF 83 | -------------------------------------------------------------------------------- /pkg/build/templates/rebuild-iso.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Template Fields 5 | # IsoExtractDir - Full path to the directory where the ISO was extracted 6 | # RawExtractDir - Full path to the directory where the RAW image was extracted 7 | # IsoSource - Full path to the original ISO that was extracted 8 | # OutputImageFilename - Full path and name of the ISO to create 9 | # CombustionDir - Full path to the combustion directory to include in the new ISO 10 | # ArtefactsDir - Full path to the artefacts directory to include in the new ISO 11 | 12 | ISO_EXTRACT_DIR={{.IsoExtractDir}} 13 | RAW_EXTRACT_DIR={{.RawExtractDir}} 14 | ISO_SOURCE={{.IsoSource}} 15 | OUTPUT_IMAGE={{.OutputImageFilename}} 16 | COMBUSTION_DIR={{.CombustionDir}} 17 | ARTEFACTS_DIR={{.ArtefactsDir}} 18 | 19 | cd ${ISO_EXTRACT_DIR} 20 | 21 | # Regenerate the checksum, overwriting the existing one that was unsquashed 22 | RAW_IMAGE_FILE=`find ${RAW_EXTRACT_DIR} -name "*.raw"` 23 | 24 | MD5_CHECKSUM_FILE=`find "${RAW_EXTRACT_DIR}" -name "*.md5"` 25 | SHA256_CHECKSUM_FILE=`find "${RAW_EXTRACT_DIR}" -name "*.sha256"` 26 | 27 | if [[ -n "$SHA256_CHECKSUM_FILE" ]]; then 28 | CHECKSUM_TYPE="sha256" 29 | CHECKSUM_FILE="$SHA256_CHECKSUM_FILE" 30 | elif [[ -n "$MD5_CHECKSUM_FILE" ]]; then 31 | CHECKSUM_TYPE="md5" 32 | CHECKSUM_FILE="$MD5_CHECKSUM_FILE" 33 | else 34 | echo "Error: No MD5 or SHA256 checksum file found in ${RAW_EXTRACT_DIR}" >&2 35 | exit 1 36 | fi 37 | 38 | BLK_CONF=$(awk '{print $2 " " $3;}' "$CHECKSUM_FILE") 39 | 40 | if [[ "$CHECKSUM_TYPE" == "md5" ]]; then 41 | echo "$(md5sum "${RAW_IMAGE_FILE}" | awk '{print $1;}') $BLK_CONF" > "$CHECKSUM_FILE" 42 | elif [[ "$CHECKSUM_TYPE" == "sha256" ]]; then 43 | echo "$(sha256sum "${RAW_IMAGE_FILE}" | awk '{print $1;}') $BLK_CONF" > "$CHECKSUM_FILE" 44 | fi 45 | 46 | # Resquash the raw image 47 | SQUASH_IMAGE_FILE=`find ${ISO_EXTRACT_DIR} -name "*.squashfs"` 48 | SQUASH_BASENAME=`basename ${SQUASH_IMAGE_FILE}` 49 | NEW_SQUASH_FILE=${RAW_EXTRACT_DIR}/${SQUASH_BASENAME} 50 | 51 | # Select the desired install device - assumes data destruction and makes the installation fully unattended by enabling GRUB timeout 52 | {{ if ne .InstallDevice "" -}} 53 | echo -e "set timeout=3\nset timeout_style=menu\n$(cat ${ISO_EXTRACT_DIR}/boot/grub2/grub.cfg)" > ${ISO_EXTRACT_DIR}/boot/grub2/grub.cfg 54 | sed -i '/root=install:CDLABEL=INSTALL/ s|$| rd.kiwi.oem.installdevice={{.InstallDevice}} |' ${ISO_EXTRACT_DIR}/boot/grub2/grub.cfg 55 | {{ end -}} 56 | 57 | # Ensure that kernel arguments are appended to ISO grub.cfg so they are applied to firstboot via kexec 58 | {{ if (gt (len .KernelArgs) 0) -}} 59 | sed -i '/root=install:CDLABEL=INSTALL/ s|$| rd.kiwi.install.pass.bootparam {{.KernelArgs}} |' ${ISO_EXTRACT_DIR}/boot/grub2/grub.cfg 60 | {{ end -}} 61 | 62 | cd ${RAW_EXTRACT_DIR} 63 | mksquashfs ${RAW_IMAGE_FILE} ${CHECKSUM_FILE} ${NEW_SQUASH_FILE} 64 | 65 | # Rebuild the previously extracted ISO with the new squashed raw image 66 | xorriso -indev ${ISO_SOURCE} \ 67 | -outdev ${OUTPUT_IMAGE} \ 68 | -map ${NEW_SQUASH_FILE} /${SQUASH_BASENAME} \ 69 | -map ${COMBUSTION_DIR} /combustion \ 70 | -map ${ARTEFACTS_DIR} /artefacts \ 71 | {{- if .InstallDevice }} 72 | -map ${ISO_EXTRACT_DIR}/boot/grub2/grub.cfg /boot/grub2/grub.cfg \ 73 | {{- end }} 74 | -boot_image any replay -changes_pending yes 75 | -------------------------------------------------------------------------------- /pkg/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "hash/fnv" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | 13 | "go.uber.org/zap" 14 | ) 15 | 16 | type Cache struct { 17 | cacheDir string 18 | } 19 | 20 | func New(cacheDir string) (*Cache, error) { 21 | return &Cache{cacheDir: cacheDir}, nil 22 | } 23 | 24 | func (cache *Cache) Get(fileIdentifier string) (path string, err error) { 25 | path, err = cache.identifierPath(fileIdentifier) 26 | if err != nil { 27 | return "", fmt.Errorf("searching for identifier '%s' in cache: %w", fileIdentifier, err) 28 | } 29 | 30 | if !exists(path) { 31 | return "", fs.ErrNotExist 32 | } 33 | 34 | return path, nil 35 | } 36 | 37 | func (cache *Cache) Put(fileIdentifier string, reader io.Reader) error { 38 | path, err := cache.identifierPath(fileIdentifier) 39 | if err != nil { 40 | return fmt.Errorf("searching for identifier '%s' in cache: %w", fileIdentifier, err) 41 | } 42 | 43 | if exists(path) { 44 | zap.S().Warnf("File with identifier '%s' already exists in cache", fileIdentifier) 45 | return fs.ErrExist 46 | } 47 | 48 | zap.S().Infof("Storing file with identifier '%s' in cache", fileIdentifier) 49 | 50 | file, err := os.Create(path) 51 | if err != nil { 52 | return fmt.Errorf("creating file: %w", err) 53 | } 54 | defer file.Close() 55 | 56 | if _, err = io.Copy(file, reader); err != nil { 57 | return fmt.Errorf("storing file: %w", err) 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func (cache *Cache) identifierPath(fileIdentifier string) (string, error) { 64 | identifier, err := identifierHash(fileIdentifier) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | return filepath.Join(cache.cacheDir, identifier), nil 70 | } 71 | 72 | func identifierHash(identifier string) (string, error) { 73 | h := fnv.New64() 74 | 75 | if _, err := h.Write([]byte(identifier)); err != nil { 76 | return "", err 77 | } 78 | 79 | hash := strconv.FormatUint(h.Sum64(), 10) 80 | 81 | zap.S().Debugf("Generated hash '%s' from identifier '%s'", hash, identifier) 82 | return hash, nil 83 | } 84 | 85 | func exists(path string) bool { 86 | if _, err := os.Stat(path); err != nil { 87 | if !errors.Is(err, fs.ErrNotExist) { 88 | zap.S().Warnf("Looking for file with identifier failed: %v", err) 89 | } 90 | 91 | return false 92 | } 93 | 94 | return true 95 | } 96 | -------------------------------------------------------------------------------- /pkg/cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func setup(t *testing.T) (cache *Cache, teardown func()) { 14 | cacheDir := "test-cache" 15 | assert.NoError(t, os.MkdirAll(cacheDir, os.ModePerm)) 16 | 17 | cache, err := New(cacheDir) 18 | require.NoError(t, err) 19 | 20 | return cache, func() { 21 | assert.NoError(t, os.RemoveAll("test-cache")) 22 | } 23 | } 24 | 25 | func TestCache(t *testing.T) { 26 | cache, teardown := setup(t) 27 | defer teardown() 28 | 29 | fileIdentifier := "some-cool-filename" 30 | fileContents := "some-data" 31 | 32 | require.NoError(t, cache.Put(fileIdentifier, strings.NewReader(fileContents))) 33 | 34 | path, err := cache.Get(fileIdentifier) 35 | require.NoError(t, err) 36 | require.FileExists(t, path) 37 | 38 | b, err := os.ReadFile(path) 39 | require.NoError(t, err) 40 | assert.Equal(t, fileContents, string(b)) 41 | } 42 | 43 | func TestCache_MissingEntry(t *testing.T) { 44 | cache, teardown := setup(t) 45 | defer teardown() 46 | 47 | fileIdentifier := "some-cool-filename" 48 | 49 | path, err := cache.Get(fileIdentifier) 50 | require.Error(t, err) 51 | 52 | assert.ErrorIs(t, err, fs.ErrNotExist) 53 | assert.NoFileExists(t, path) 54 | } 55 | 56 | func TestCache_DoubleInsert(t *testing.T) { 57 | cache, teardown := setup(t) 58 | defer teardown() 59 | 60 | fileIdentifier := "https://raw.githubusercontent.com/suse-edge/edge-image-builder/main/README.md" 61 | fileContents := "some-data" 62 | 63 | require.NoError(t, cache.Put(fileIdentifier, strings.NewReader(fileContents))) 64 | assert.ErrorIs(t, cache.Put(fileIdentifier, strings.NewReader(fileContents)), fs.ErrExist) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/cli/build/validate.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/suse-edge/edge-image-builder/pkg/cli/cmd" 12 | "github.com/suse-edge/edge-image-builder/pkg/image" 13 | "github.com/suse-edge/edge-image-builder/pkg/image/validation" 14 | "github.com/suse-edge/edge-image-builder/pkg/log" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | const ( 19 | checkValidationLogMessage = "Please check the log file under the validation directory for more information." 20 | ) 21 | 22 | func Validate(_ *cli.Context) error { 23 | args := &cmd.BuildArgs 24 | 25 | validationDir := filepath.Join(args.ConfigDir, "_validation") 26 | if err := os.MkdirAll(validationDir, os.ModePerm); err != nil { 27 | log.Auditf("The validation directory could not be setup under the configuration directory '%s'.", args.ConfigDir) 28 | return err 29 | } 30 | 31 | // This needs to occur as early as possible so that the subsequent calls can use the log 32 | timestamp := time.Now().Format("Jan02_15-04-05") 33 | logFilename := filepath.Join(validationDir, fmt.Sprintf("eib-validate-%s.log", timestamp)) 34 | log.ConfigureGlobalLogger(logFilename) 35 | 36 | log.AuditInfo("Checking image config dir...") 37 | 38 | if err := imageConfigDirExists(args.ConfigDir); err != nil { 39 | cmd.LogError(err, checkValidationLogMessage) 40 | os.Exit(1) 41 | } 42 | 43 | log.AuditInfo("Parsing image definition...") 44 | 45 | imageDefinition, err := parseImageDefinition(args.ConfigDir, args.DefinitionFile) 46 | if err != nil { 47 | cmd.LogError(err, checkValidationLogMessage) 48 | os.Exit(1) 49 | } 50 | 51 | ctx := &image.Context{ 52 | ImageConfigDir: args.ConfigDir, 53 | ImageDefinition: imageDefinition, 54 | } 55 | 56 | log.AuditInfo("Validating image definition...") 57 | 58 | if err = validateImageDefinition(ctx); err != nil { 59 | cmd.LogError(err, checkValidationLogMessage) 60 | os.Exit(1) 61 | } 62 | 63 | log.AuditInfo("The specified image definition is valid.") 64 | 65 | return nil 66 | } 67 | 68 | func validateImageDefinition(ctx *image.Context) *cmd.Error { 69 | failedValidations := validation.ValidateDefinition(ctx) 70 | if len(failedValidations) == 0 { 71 | return nil 72 | } 73 | 74 | logMessageBuilder := strings.Builder{} 75 | userMessageBuilder := strings.Builder{} 76 | 77 | userMessageBuilder.WriteString("Image definition validation found the following errors:\n") 78 | logMessageBuilder.WriteString("Image definition validation failures:\n") 79 | 80 | orderedComponentNames := make([]string, 0, len(failedValidations)) 81 | for c := range failedValidations { 82 | orderedComponentNames = append(orderedComponentNames, c) 83 | } 84 | slices.Sort(orderedComponentNames) 85 | 86 | for _, componentName := range orderedComponentNames { 87 | userMessageBuilder.WriteString(" " + componentName + "\n") 88 | 89 | for _, cf := range failedValidations[componentName] { 90 | userMessageBuilder.WriteString(" " + cf.UserMessage + "\n") 91 | logMessageBuilder.WriteString(" " + cf.UserMessage + "\n") 92 | if cf.Error != nil { 93 | logMessageBuilder.WriteString(" " + cf.Error.Error() + "\n") 94 | } 95 | } 96 | } 97 | 98 | return &cmd.Error{ 99 | UserMessage: userMessageBuilder.String(), 100 | LogMessage: logMessageBuilder.String(), 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/cli/build/version.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/suse-edge/edge-image-builder/pkg/log" 7 | "github.com/suse-edge/edge-image-builder/pkg/version" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func Version(_ *cli.Context) error { 12 | log.Auditf("Edge Image Builder Version: %s", version.GetEibVersion()) 13 | log.Auditf("Supported schema versions: %s", strings.Join(version.SupportedSchemaVersions, ", ")) 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /pkg/cli/cmd/build.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | type BuildFlags struct { 10 | DefinitionFile string 11 | ConfigDir string 12 | RootBuildDir string 13 | } 14 | 15 | var BuildArgs BuildFlags 16 | 17 | func NewBuildCommand(action func(*cli.Context) error) *cli.Command { 18 | return &cli.Command{ 19 | Name: "build", 20 | Usage: "Build new image", 21 | UsageText: fmt.Sprintf("%s build [OPTIONS]", appName), 22 | Action: action, 23 | Flags: []cli.Flag{ 24 | DefinitionFileFlag, 25 | ConfigDirFlag, 26 | &cli.StringFlag{ 27 | Name: "build-dir", 28 | Usage: "Full path to the directory to store build artifacts", 29 | Destination: &BuildArgs.RootBuildDir, 30 | }, 31 | }, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/cli/cmd/error.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | 6 | "github.com/suse-edge/edge-image-builder/pkg/log" 7 | ) 8 | 9 | type Error struct { 10 | UserMessage string 11 | LogMessage string 12 | } 13 | 14 | func LogError(err *Error, checkLogMessage string) { 15 | if err.LogMessage == "" { 16 | log.AuditError(err.UserMessage) 17 | return 18 | } 19 | 20 | log.Audit(err.UserMessage) 21 | log.Audit(checkLogMessage) 22 | zap.S().Error(err.LogMessage) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/cli/cmd/flags.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var ( 6 | DefinitionFileFlag = &cli.StringFlag{ 7 | Name: "definition-file", 8 | Usage: "Name of the image definition file", 9 | Destination: &BuildArgs.DefinitionFile, 10 | } 11 | ConfigDirFlag = &cli.StringFlag{ 12 | Name: "config-dir", 13 | Usage: "Full path to the image configuration directory", 14 | Value: "/eib", 15 | Destination: &BuildArgs.ConfigDir, 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /pkg/cli/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var appName = filepath.Base(os.Args[0]) 11 | 12 | func NewApp() *cli.App { 13 | app := cli.NewApp() 14 | app.Name = appName 15 | app.Usage = "Edge Image Builder" 16 | 17 | return app 18 | } 19 | -------------------------------------------------------------------------------- /pkg/cli/cmd/validate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func NewValidateCommand(action func(*cli.Context) error) *cli.Command { 10 | return &cli.Command{ 11 | Name: "validate", 12 | Usage: "Validate image configuration", 13 | UsageText: fmt.Sprintf("%s validate [OPTIONS]", appName), 14 | Action: action, 15 | Flags: []cli.Flag{ 16 | DefinitionFileFlag, 17 | ConfigDirFlag, 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/cli/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func NewVersionCommand(action func(*cli.Context) error) *cli.Command { 10 | return &cli.Command{ 11 | Name: "version", 12 | Usage: "Inspect program version", 13 | UsageText: fmt.Sprintf("%s version", appName), 14 | Action: action, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pkg/combustion/certificates.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | const ( 17 | certsComponentName = "certificates" 18 | certsScriptName = "07-certificates.sh" 19 | certsConfigDir = "certificates" 20 | ) 21 | 22 | //go:embed templates/07-certificates.sh.tpl 23 | var certsScriptTemplate string 24 | 25 | func configureCertificates(ctx *image.Context) ([]string, error) { 26 | if !isComponentConfigured(ctx, certsConfigDir) { 27 | log.AuditComponentSkipped(certsComponentName) 28 | zap.S().Info("skipping certificate configuration, no certificates provided") 29 | return nil, nil 30 | } 31 | 32 | if err := copyCertificates(ctx); err != nil { 33 | log.AuditComponentFailed(certsComponentName) 34 | return nil, err 35 | } 36 | 37 | if err := writeCertificatesScript(ctx); err != nil { 38 | log.AuditComponentFailed(certsComponentName) 39 | return nil, err 40 | } 41 | 42 | log.AuditComponentSuccessful(certsComponentName) 43 | return []string{certsScriptName}, nil 44 | } 45 | 46 | func copyCertificates(ctx *image.Context) error { 47 | srcDir := filepath.Join(ctx.ImageConfigDir, certsConfigDir) 48 | destDir := filepath.Join(ctx.CombustionDir, certsConfigDir) 49 | 50 | dirEntries, err := os.ReadDir(srcDir) 51 | if err != nil { 52 | return fmt.Errorf("reading the certificates directory at %s: %w", srcDir, err) 53 | } 54 | 55 | if len(dirEntries) == 0 { 56 | return fmt.Errorf("no certificates found in directory %s", srcDir) 57 | } 58 | 59 | if err := os.MkdirAll(destDir, os.ModePerm); err != nil { 60 | return fmt.Errorf("creating certificates directory '%s': %w", destDir, err) 61 | } 62 | 63 | if err := fileio.CopyFiles(srcDir, destDir, ".pem", false, nil); err != nil { 64 | return fmt.Errorf("copying pem files: %w", err) 65 | } 66 | 67 | if err := fileio.CopyFiles(srcDir, destDir, ".crt", false, nil); err != nil { 68 | return fmt.Errorf("copying certificates: %w", err) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func writeCertificatesScript(ctx *image.Context) error { 75 | destFilename := filepath.Join(ctx.CombustionDir, certsScriptName) 76 | 77 | values := struct { 78 | CertificatesDir string 79 | }{ 80 | CertificatesDir: certsConfigDir, 81 | } 82 | data, err := template.Parse(certsScriptName, certsScriptTemplate, &values) 83 | if err != nil { 84 | return fmt.Errorf("applying template to %s: %w", certsScriptName, err) 85 | } 86 | 87 | if err := os.WriteFile(destFilename, []byte(data), fileio.ExecutablePerms); err != nil { 88 | return fmt.Errorf("writing file %s: %w", destFilename, err) 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/combustion/certificates_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func setupCertificatesConfigDir(t *testing.T) (ctx *image.Context, teardown func()) { 15 | ctx, teardown = setupContext(t) 16 | 17 | testCertsDir := filepath.Join(ctx.ImageConfigDir, certsConfigDir) 18 | err := os.Mkdir(testCertsDir, 0o755) 19 | require.NoError(t, err) 20 | 21 | testFilenames := []string{"foo", "bar.pem", "baz.pem", "wombat.crt"} 22 | for _, filename := range testFilenames { 23 | path := filepath.Join(testCertsDir, filename) 24 | err = os.WriteFile(path, []byte(""), 0o600) 25 | require.NoError(t, err) 26 | } 27 | 28 | return 29 | } 30 | 31 | func TestCopyCertificatesEmptyDirectory(t *testing.T) { 32 | // Setup 33 | ctx, teardown := setupContext(t) 34 | defer teardown() 35 | 36 | testCertsDir := filepath.Join(ctx.ImageConfigDir, certsConfigDir) 37 | err := os.Mkdir(testCertsDir, 0o755) 38 | require.NoError(t, err) 39 | defer os.RemoveAll(testCertsDir) 40 | 41 | // Test 42 | err = copyCertificates(ctx) 43 | 44 | // Verify 45 | require.Error(t, err) 46 | } 47 | 48 | func TestCopyCertificates(t *testing.T) { 49 | // Setup 50 | ctx, teardown := setupCertificatesConfigDir(t) 51 | defer teardown() 52 | 53 | // Test 54 | err := copyCertificates(ctx) 55 | 56 | // Verify 57 | require.NoError(t, err) 58 | 59 | expectedCertsDir := filepath.Join(ctx.CombustionDir, certsConfigDir) 60 | expectedFilenames := []string{"bar.pem", "baz.pem", "wombat.crt"} 61 | entries, err := os.ReadDir(expectedCertsDir) 62 | require.NoError(t, err) 63 | assert.Len(t, entries, len(expectedFilenames)) 64 | for _, entry := range entries { 65 | assert.Contains(t, expectedFilenames, entry.Name()) 66 | } 67 | } 68 | 69 | func TestWriteCertificatesScript(t *testing.T) { 70 | // Setup 71 | ctx, teardown := setupContext(t) 72 | defer teardown() 73 | 74 | // Test 75 | err := writeCertificatesScript(ctx) 76 | 77 | // Verify 78 | require.NoError(t, err) 79 | 80 | scriptFilename := filepath.Join(ctx.CombustionDir, certsScriptName) 81 | foundBytes, err := os.ReadFile(scriptFilename) 82 | require.NoError(t, err) 83 | found := string(foundBytes) 84 | assert.Contains(t, found, fmt.Sprintf("cp ./%s/* /etc/pki/trust/anchors/.", certsConfigDir)) 85 | assert.Contains(t, found, "update-ca-certificates") 86 | } 87 | -------------------------------------------------------------------------------- /pkg/combustion/cleanup.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | const ( 16 | cleanupScriptName = "cleanup-combustion.sh" 17 | cleanupComponentName = "cleanup" 18 | ) 19 | 20 | //go:embed templates/cleanup-combustion.sh 21 | var cleanupScript string 22 | 23 | func configureCleanup(ctx *image.Context) ([]string, error) { 24 | if ctx.ImageDefinition.Image.ImageType != image.TypeRAW { 25 | log.AuditComponentSkipped(cleanupComponentName) 26 | zap.S().Info("skipping cleanup component, image type is not raw") 27 | return nil, nil 28 | } 29 | 30 | cleanupScriptFilename := filepath.Join(ctx.CombustionDir, cleanupScriptName) 31 | if err := os.WriteFile(cleanupScriptFilename, []byte(cleanupScript), fileio.ExecutablePerms); err != nil { 32 | return nil, fmt.Errorf("writing cleanup files script %s: %w", cleanupScriptName, err) 33 | } 34 | 35 | log.AuditComponentSuccessful(cleanupComponentName) 36 | return []string{cleanupScriptName}, nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/combustion/cleanup_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/suse-edge/edge-image-builder/pkg/image" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestConfigureCleanupRaw(t *testing.T) { 15 | // Setup 16 | ctx, teardown := setupContext(t) 17 | defer teardown() 18 | ctx.ImageDefinition.Image.ImageType = image.TypeRAW 19 | 20 | // Test 21 | scriptNames, err := configureCleanup(ctx) 22 | 23 | // Verify 24 | require.NoError(t, err) 25 | 26 | assert.Equal(t, []string{cleanupScriptName}, scriptNames) 27 | 28 | // -- Combustion Script 29 | expectedCombustionScript := filepath.Join(ctx.CombustionDir, cleanupScriptName) 30 | contents, err := os.ReadFile(expectedCombustionScript) 31 | require.NoError(t, err) 32 | assert.Contains(t, string(contents), "rm -r /artefacts") 33 | } 34 | 35 | func TestConfigureCleanupISO(t *testing.T) { 36 | // Setup 37 | ctx, teardown := setupContext(t) 38 | defer teardown() 39 | ctx.ImageDefinition.Image.ImageType = image.TypeISO 40 | 41 | // Test 42 | scriptNames, err := configureCleanup(ctx) 43 | 44 | // Verify 45 | require.NoError(t, err) 46 | 47 | assert.NotEqual(t, []string{cleanupScriptName}, scriptNames) 48 | 49 | // -- Combustion Script 50 | expectedCombustionScript := filepath.Join(ctx.CombustionDir, cleanupScriptName) 51 | assert.NoFileExists(t, expectedCombustionScript) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/combustion/combustion_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | ) 12 | 13 | func setupContext(t *testing.T) (ctx *image.Context, teardown func()) { 14 | configDir, err := os.MkdirTemp("", "eib-config-") 15 | require.NoError(t, err) 16 | 17 | buildDir, err := os.MkdirTemp("", "eib-build-") 18 | require.NoError(t, err) 19 | 20 | combustionDir, err := os.MkdirTemp("", "eib-combustion-") 21 | require.NoError(t, err) 22 | 23 | artefactsDir, err := os.MkdirTemp("", "eib-artefacts-") 24 | require.NoError(t, err) 25 | 26 | ctx = &image.Context{ 27 | ImageConfigDir: configDir, 28 | BuildDir: buildDir, 29 | CombustionDir: combustionDir, 30 | ArtefactsDir: artefactsDir, 31 | ImageDefinition: &image.Definition{}, 32 | } 33 | 34 | return ctx, func() { 35 | assert.NoError(t, os.RemoveAll(combustionDir)) 36 | assert.NoError(t, os.RemoveAll(buildDir)) 37 | assert.NoError(t, os.RemoveAll(artefactsDir)) 38 | assert.NoError(t, os.RemoveAll(configDir)) 39 | } 40 | } 41 | 42 | func TestGenerateComponentPath(t *testing.T) { 43 | // Setup 44 | ctx, teardown := setupContext(t) 45 | defer teardown() 46 | 47 | componentDir := filepath.Join(ctx.ImageConfigDir, "some-component") 48 | require.NoError(t, os.Mkdir(componentDir, 0o755)) 49 | 50 | // Test 51 | generatedPath := generateComponentPath(ctx, "some-component") 52 | 53 | // Verify 54 | assert.Equal(t, componentDir, generatedPath) 55 | } 56 | 57 | func TestIsComponentConfigured(t *testing.T) { 58 | ctx, teardown := setupContext(t) 59 | defer teardown() 60 | 61 | componentDir := filepath.Join(ctx.ImageConfigDir, "existing-component") 62 | require.NoError(t, os.Mkdir(componentDir, 0o755)) 63 | 64 | assert.True(t, isComponentConfigured(ctx, "existing-component")) 65 | assert.False(t, isComponentConfigured(ctx, "missing-component")) 66 | assert.False(t, isComponentConfigured(ctx, "")) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/combustion/custom.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 9 | "github.com/suse-edge/edge-image-builder/pkg/image" 10 | "github.com/suse-edge/edge-image-builder/pkg/log" 11 | ) 12 | 13 | const ( 14 | customDir = "custom" 15 | customScriptsDir = "scripts" 16 | customFilesDir = "files" 17 | customComponentName = "custom files" 18 | ) 19 | 20 | func configureCustomFiles(ctx *image.Context) ([]string, error) { 21 | if !isComponentConfigured(ctx, customDir) { 22 | log.AuditComponentSkipped(customComponentName) 23 | return nil, nil 24 | } 25 | 26 | err := handleCustomFiles(ctx) 27 | if err != nil { 28 | log.AuditComponentFailed(customComponentName) 29 | return nil, err 30 | } 31 | 32 | scripts, err := handleCustomScripts(ctx) 33 | if err != nil { 34 | log.AuditComponentFailed(customComponentName) 35 | return nil, err 36 | } 37 | 38 | log.AuditComponentSuccessful(customComponentName) 39 | return scripts, nil 40 | } 41 | 42 | func handleCustomFiles(ctx *image.Context) error { 43 | fullFilesDir := generateComponentPath(ctx, filepath.Join(customDir, customFilesDir)) 44 | err := copyCustomFiles(fullFilesDir, ctx.CombustionDir) 45 | return err 46 | } 47 | 48 | func handleCustomScripts(ctx *image.Context) ([]string, error) { 49 | fullScriptsDir := generateComponentPath(ctx, filepath.Join(customDir, customScriptsDir)) 50 | scripts, err := copyCustomScripts(fullScriptsDir, ctx.CombustionDir, &fileio.ExecutablePerms) 51 | return scripts, err 52 | } 53 | 54 | func copyCustomFiles(fromDir, toDir string) error { 55 | if _, err := os.Stat(fromDir); os.IsNotExist(err) { 56 | return nil 57 | } 58 | 59 | dirEntries, err := os.ReadDir(fromDir) 60 | if err != nil { 61 | return fmt.Errorf("reading the custom files directory at %s: %w", fromDir, err) 62 | } 63 | 64 | // If the directory exists but there's nothing in it, consider it an error case 65 | if len(dirEntries) == 0 { 66 | return fmt.Errorf("no files found in directory %s", fromDir) 67 | } 68 | 69 | if err = fileio.CopyFiles(fromDir, toDir, "", true, nil); err != nil { 70 | return fmt.Errorf("copying custom files and directories: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func copyCustomScripts(fromDir, toDir string, filePermissions *os.FileMode) ([]string, error) { 77 | if _, err := os.Stat(fromDir); os.IsNotExist(err) { 78 | return nil, nil 79 | } 80 | 81 | dirEntries, err := os.ReadDir(fromDir) 82 | if err != nil { 83 | return nil, fmt.Errorf("reading the custom scripts directory at %s: %w", fromDir, err) 84 | } 85 | 86 | // If the directory exists but there's nothing in it, consider it an error case 87 | if len(dirEntries) == 0 { 88 | return nil, fmt.Errorf("no scripts found in directory %s", fromDir) 89 | } 90 | 91 | var copiedFiles []string 92 | 93 | for _, entry := range dirEntries { 94 | copyMe := filepath.Join(fromDir, entry.Name()) 95 | copyTo := filepath.Join(toDir, entry.Name()) 96 | 97 | if err = fileio.CopyFile(copyMe, copyTo, *filePermissions); err != nil { 98 | return nil, fmt.Errorf("copying script to %s: %w", copyTo, err) 99 | } 100 | 101 | copiedFiles = append(copiedFiles, entry.Name()) 102 | } 103 | 104 | return copiedFiles, nil 105 | 106 | } 107 | -------------------------------------------------------------------------------- /pkg/combustion/custom_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 12 | ) 13 | 14 | func TestConfigureCustomFiles(t *testing.T) { 15 | // Setup 16 | ctx, teardown := setupContext(t) 17 | defer teardown() 18 | 19 | scriptsDir := filepath.Join(ctx.ImageConfigDir, customDir, customScriptsDir) 20 | require.NoError(t, os.MkdirAll(scriptsDir, os.ModePerm)) 21 | 22 | filesDir := filepath.Join(ctx.ImageConfigDir, customDir, customFilesDir) 23 | require.NoError(t, os.MkdirAll(filesDir, os.ModePerm)) 24 | 25 | files := map[string]struct { 26 | isScript bool 27 | perms fs.FileMode 28 | }{ 29 | "foo.sh": { 30 | isScript: true, 31 | perms: 0o744, 32 | }, 33 | "bar.sh": { 34 | isScript: true, 35 | perms: 0o755, 36 | }, 37 | "baz": { 38 | isScript: false, 39 | perms: 0o744, 40 | }, 41 | "qux": { 42 | isScript: false, 43 | perms: 0o644, 44 | }, 45 | } 46 | 47 | for filename, info := range files { 48 | var path string 49 | 50 | if info.isScript { 51 | path = filepath.Join(scriptsDir, filename) 52 | } else { 53 | path = filepath.Join(filesDir, filename) 54 | } 55 | 56 | require.NoError(t, os.WriteFile(path, nil, info.perms)) 57 | } 58 | 59 | // Test 60 | scripts, err := configureCustomFiles(ctx) 61 | 62 | // Verify 63 | require.NoError(t, err) 64 | 65 | // - make sure the files were added to the build directory 66 | dirEntries, err := os.ReadDir(ctx.CombustionDir) 67 | require.NoError(t, err) 68 | require.Len(t, dirEntries, 4) 69 | 70 | // - make sure the copied files have the right permissions 71 | for _, entry := range dirEntries { 72 | file, ok := files[entry.Name()] 73 | require.Truef(t, ok, "Unexpected file: %s", entry.Name()) 74 | 75 | entryPath := filepath.Join(ctx.CombustionDir, entry.Name()) 76 | stats, err := os.Stat(entryPath) 77 | require.NoError(t, err) 78 | 79 | if files[entry.Name()].isScript { 80 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 81 | } else { 82 | assert.Equal(t, file.perms, stats.Mode()) 83 | } 84 | } 85 | 86 | // - make sure only script entries were added to the combustion scripts list 87 | require.Len(t, scripts, 2) 88 | assert.Contains(t, scripts, "foo.sh") 89 | assert.Contains(t, scripts, "bar.sh") 90 | } 91 | 92 | func TestConfigureFiles_NoCustomDir(t *testing.T) { 93 | // Setup 94 | ctx, teardown := setupContext(t) 95 | defer teardown() 96 | 97 | // Test 98 | scripts, err := configureCustomFiles(ctx) 99 | 100 | // Verify 101 | require.NoError(t, err) 102 | assert.Nil(t, scripts) 103 | } 104 | 105 | func TestCopyCustomFiles_MissingFromDir(t *testing.T) { 106 | // Setup 107 | ctx, teardown := setupContext(t) 108 | defer teardown() 109 | 110 | // Test 111 | err := copyCustomFiles("missing", ctx.CombustionDir) 112 | 113 | // Verify 114 | assert.Nil(t, err) 115 | } 116 | 117 | func TestCopyCustomFiles_EmptyFromDir(t *testing.T) { 118 | // Setup 119 | ctx, teardown := setupContext(t) 120 | defer teardown() 121 | 122 | // - from directory to look in 123 | fullScriptsDir := filepath.Join(ctx.ImageConfigDir, customDir, customScriptsDir) 124 | err := os.MkdirAll(fullScriptsDir, os.ModePerm) 125 | require.NoError(t, err) 126 | 127 | // Test 128 | scripts, err := copyCustomScripts(fullScriptsDir, ctx.CombustionDir, nil) 129 | 130 | // Verify 131 | require.Error(t, err) 132 | assert.ErrorContains(t, err, "no scripts found in directory") 133 | assert.Nil(t, scripts) 134 | } 135 | -------------------------------------------------------------------------------- /pkg/combustion/elemental.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | const ( 17 | elementalComponentName = "elemental" 18 | elementalConfigDir = "elemental" 19 | elementalScriptName = "31-elemental.sh" 20 | elementalConfigName = "elemental_config.yaml" 21 | ) 22 | 23 | var ( 24 | //go:embed templates/31-elemental-register.sh.tpl 25 | elementalScript string 26 | 27 | ElementalPackages = []string{"elemental-register", "elemental-system-agent"} 28 | ) 29 | 30 | func configureElemental(ctx *image.Context) ([]string, error) { 31 | if !isComponentConfigured(ctx, elementalConfigDir) { 32 | log.AuditComponentSkipped(elementalComponentName) 33 | zap.S().Info("Skipping elemental registration component, configuration is not provided") 34 | return nil, nil 35 | } 36 | 37 | if err := copyElementalConfigFile(ctx); err != nil { 38 | log.AuditComponentFailed(elementalComponentName) 39 | return nil, err 40 | } 41 | 42 | if err := writeElementalCombustionScript(ctx); err != nil { 43 | log.AuditComponentFailed(elementalComponentName) 44 | return nil, err 45 | } 46 | 47 | log.AuditComponentSuccessful(elementalComponentName) 48 | return []string{elementalScriptName}, nil 49 | } 50 | 51 | func copyElementalConfigFile(ctx *image.Context) error { 52 | srcFile := filepath.Join(ctx.ImageConfigDir, elementalConfigDir, elementalConfigName) 53 | destFile := filepath.Join(ctx.CombustionDir, elementalConfigName) 54 | 55 | err := fileio.CopyFile(srcFile, destFile, fileio.NonExecutablePerms) 56 | if err != nil { 57 | return fmt.Errorf("error copying elemental config file %s: %w", srcFile, err) 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func writeElementalCombustionScript(ctx *image.Context) error { 64 | elementalScriptFilename := filepath.Join(ctx.CombustionDir, elementalScriptName) 65 | 66 | values := struct { 67 | ConfigFile string 68 | }{ 69 | ConfigFile: elementalConfigName, 70 | } 71 | data, err := template.Parse(elementalScriptName, elementalScript, &values) 72 | if err != nil { 73 | return fmt.Errorf("applying template to %s: %w", elementalScriptName, err) 74 | } 75 | 76 | if err := os.WriteFile(elementalScriptFilename, []byte(data), fileio.ExecutablePerms); err != nil { 77 | return fmt.Errorf("writing file %s: %w", elementalScriptFilename, err) 78 | } 79 | return nil 80 | } 81 | 82 | func ElementalPath(ctx *image.Context) string { 83 | return filepath.Join(ctx.ImageConfigDir, elementalConfigDir) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/combustion/elemental_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | ) 12 | 13 | func setupElementalConfigDir(t *testing.T) (ctx *image.Context, teardown func()) { 14 | ctx, teardownCtx := setupContext(t) 15 | 16 | testConfigDir := filepath.Join(ctx.ImageConfigDir, elementalConfigDir) 17 | err := os.Mkdir(testConfigDir, 0o755) 18 | require.NoError(t, err) 19 | 20 | testConfigFile := filepath.Join(testConfigDir, elementalConfigName) 21 | contents := "foo: bar" 22 | err = os.WriteFile(testConfigFile, []byte(contents), 0o600) 23 | require.NoError(t, err) 24 | 25 | teardown = teardownCtx 26 | 27 | return 28 | } 29 | 30 | func TestCopyElementalConfigFile(t *testing.T) { 31 | // Setup 32 | ctx, teardown := setupElementalConfigDir(t) 33 | defer teardown() 34 | 35 | // Test 36 | err := copyElementalConfigFile(ctx) 37 | 38 | // Verify 39 | require.NoError(t, err) 40 | 41 | foundFile := filepath.Join(ctx.CombustionDir, elementalConfigName) 42 | found, err := os.ReadFile(foundFile) 43 | require.NoError(t, err) 44 | assert.Equal(t, "foo: bar", string(found)) 45 | } 46 | 47 | func TestWriteElementalCombustionScript(t *testing.T) { 48 | // Setup 49 | ctx, teardown := setupContext(t) 50 | defer teardown() 51 | 52 | // Test 53 | err := writeElementalCombustionScript(ctx) 54 | 55 | // Verify 56 | require.NoError(t, err) 57 | 58 | scriptFilename := filepath.Join(ctx.CombustionDir, elementalScriptName) 59 | _, err = os.Stat(scriptFilename) 60 | require.NoError(t, err) 61 | 62 | foundBytes, err := os.ReadFile(scriptFilename) 63 | require.NoError(t, err) 64 | found := string(foundBytes) 65 | assert.Contains(t, found, "/usr/sbin/elemental-register --debug --config-path /etc/elemental/config.yaml --state-path /etc/elemental/state.yaml --install --no-toolkit") 66 | assert.Contains(t, found, "/etc/systemd/system/elemental-reset.path") 67 | assert.Contains(t, found, "/etc/systemd/system/elemental-reset.service") 68 | assert.Contains(t, found, "mkdir -p /opt/edge/") 69 | assert.Contains(t, found, "cat <<- \\EOF > /opt/edge/elemental_node_cleanup.sh") 70 | } 71 | -------------------------------------------------------------------------------- /pkg/combustion/fips.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | ) 13 | 14 | const ( 15 | fipsComponentName = "fips" 16 | fipsScriptName = "15-fips-setup.sh" 17 | ) 18 | 19 | var ( 20 | //go:embed templates/15-fips-setup.sh 21 | fipsScript string 22 | FIPSPackages = []string{"patterns-base-fips"} 23 | FIPSKernelArgs = []string{"fips=1"} 24 | ) 25 | 26 | func configureFIPS(ctx *image.Context) ([]string, error) { 27 | fips := ctx.ImageDefinition.OperatingSystem.EnableFIPS 28 | if !fips { 29 | log.AuditComponentSkipped(fipsComponentName) 30 | return nil, nil 31 | } 32 | 33 | if err := writeFIPSCombustionScript(ctx); err != nil { 34 | log.AuditComponentFailed(fipsComponentName) 35 | return nil, err 36 | } 37 | 38 | log.AuditComponentSuccessful(fipsComponentName) 39 | return []string{fipsScriptName}, nil 40 | } 41 | 42 | func writeFIPSCombustionScript(ctx *image.Context) error { 43 | fipsScriptFilename := filepath.Join(ctx.CombustionDir, fipsScriptName) 44 | 45 | if err := os.WriteFile(fipsScriptFilename, []byte(fipsScript), fileio.ExecutablePerms); err != nil { 46 | return fmt.Errorf("writing file %s: %w", fipsScriptFilename, err) 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/combustion/fips_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureFIPS_NoConf(t *testing.T) { 15 | // Setup 16 | var ctx image.Context 17 | 18 | ctx.ImageDefinition = &image.Definition{ 19 | OperatingSystem: image.OperatingSystem{}, 20 | } 21 | 22 | // Test 23 | scripts, err := configureFIPS(&ctx) 24 | 25 | // Verify 26 | require.NoError(t, err) 27 | assert.Nil(t, scripts) 28 | } 29 | 30 | func TestConfigureFIPS_Enabled(t *testing.T) { 31 | // Setup 32 | ctx, teardown := setupContext(t) 33 | defer teardown() 34 | 35 | ctx.ImageDefinition = &image.Definition{ 36 | OperatingSystem: image.OperatingSystem{ 37 | EnableFIPS: true, 38 | }, 39 | } 40 | 41 | // Test 42 | scripts, err := configureFIPS(ctx) 43 | 44 | // Verify 45 | require.NoError(t, err) 46 | 47 | require.Len(t, scripts, 1) 48 | assert.Equal(t, fipsScriptName, scripts[0]) 49 | 50 | expectedFilename := filepath.Join(ctx.CombustionDir, fipsScriptName) 51 | foundBytes, err := os.ReadFile(expectedFilename) 52 | require.NoError(t, err) 53 | 54 | stats, err := os.Stat(expectedFilename) 55 | require.NoError(t, err) 56 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 57 | 58 | foundContents := string(foundBytes) 59 | 60 | // - Ensure that we have the fips setup script defined 61 | assert.Contains(t, foundContents, "fips-mode-setup --enable", "fips setup script missing") 62 | } 63 | -------------------------------------------------------------------------------- /pkg/combustion/groups.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | ) 14 | 15 | const ( 16 | groupsScriptName = "13a-groups.sh" 17 | groupsComponentName = "groups" 18 | ) 19 | 20 | //go:embed templates/13a-add-groups.sh.tpl 21 | var groupsScript string 22 | 23 | func configureGroups(ctx *image.Context) ([]string, error) { 24 | // Punch out early if there are no groups 25 | if len(ctx.ImageDefinition.OperatingSystem.Groups) == 0 { 26 | log.AuditComponentSkipped(groupsComponentName) 27 | return nil, nil 28 | } 29 | 30 | data, err := template.Parse(groupsScriptName, groupsScript, ctx.ImageDefinition.OperatingSystem.Groups) 31 | if err != nil { 32 | log.AuditComponentFailed(groupsComponentName) 33 | return nil, fmt.Errorf("parsing the group script template: %w", err) 34 | } 35 | 36 | filename := filepath.Join(ctx.CombustionDir, groupsScriptName) 37 | err = os.WriteFile(filename, []byte(data), fileio.ExecutablePerms) 38 | if err != nil { 39 | log.AuditComponentFailed(groupsComponentName) 40 | return nil, fmt.Errorf("writing %s to the combustion directory: %w", groupsScriptName, err) 41 | } 42 | 43 | log.AuditComponentSuccessful(groupsComponentName) 44 | return []string{groupsScriptName}, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/combustion/groups_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureGroups(t *testing.T) { 15 | // Setup 16 | ctx, teardown := setupContext(t) 17 | defer teardown() 18 | 19 | ctx.ImageDefinition = &image.Definition{ 20 | OperatingSystem: image.OperatingSystem{ 21 | Groups: []image.OperatingSystemGroup{ 22 | { 23 | Name: "group1", 24 | GID: 1000, 25 | }, 26 | { 27 | Name: "group2", 28 | }, 29 | }, 30 | }, 31 | } 32 | 33 | // Test 34 | scripts, err := configureGroups(ctx) 35 | 36 | // Verify 37 | require.NoError(t, err) 38 | 39 | require.Len(t, scripts, 1) 40 | assert.Equal(t, groupsScriptName, scripts[0]) 41 | 42 | expectedFilename := filepath.Join(ctx.CombustionDir, groupsScriptName) 43 | foundBytes, err := os.ReadFile(expectedFilename) 44 | require.NoError(t, err) 45 | 46 | stats, err := os.Stat(expectedFilename) 47 | require.NoError(t, err) 48 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 49 | 50 | foundContents := string(foundBytes) 51 | 52 | assert.Contains(t, foundContents, "groupadd -f -g 1000 group1") 53 | assert.Contains(t, foundContents, "groupadd -f group2") 54 | } 55 | -------------------------------------------------------------------------------- /pkg/combustion/helm.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "github.com/suse-edge/edge-image-builder/pkg/image" 5 | ) 6 | 7 | func ComponentHelmCharts(ctx *image.Context) ([]image.HelmChart, []image.HelmRepository) { 8 | if ctx.ImageDefinition.Kubernetes.Version == "" { 9 | return nil, nil 10 | } 11 | 12 | const ( 13 | metallbRepositoryName = "suse-edge-metallb" 14 | metallbNamespace = "metallb-system" 15 | 16 | endpointCopierOperatorRepositoryName = "suse-edge-endpoint-copier-operator" 17 | endpointCopierOperatorNamespace = "endpoint-copier-operator" 18 | 19 | installationNamespace = "kube-system" 20 | ) 21 | 22 | var charts []image.HelmChart 23 | var repos []image.HelmRepository 24 | 25 | if ctx.ImageDefinition.Kubernetes.Network.APIVIP4 != "" || ctx.ImageDefinition.Kubernetes.Network.APIVIP6 != "" { 26 | metalLBChart := image.HelmChart{ 27 | Name: ctx.ArtifactSources.MetalLB.Chart, 28 | RepositoryName: metallbRepositoryName, 29 | TargetNamespace: metallbNamespace, 30 | CreateNamespace: true, 31 | InstallationNamespace: installationNamespace, 32 | Version: ctx.ArtifactSources.MetalLB.Version, 33 | } 34 | 35 | endpointCopierOperatorChart := image.HelmChart{ 36 | Name: ctx.ArtifactSources.EndpointCopierOperator.Chart, 37 | RepositoryName: endpointCopierOperatorRepositoryName, 38 | TargetNamespace: endpointCopierOperatorNamespace, 39 | CreateNamespace: true, 40 | InstallationNamespace: installationNamespace, 41 | Version: ctx.ArtifactSources.EndpointCopierOperator.Version, 42 | } 43 | 44 | charts = append(charts, metalLBChart, endpointCopierOperatorChart) 45 | 46 | metallbRepo := image.HelmRepository{ 47 | Name: metallbRepositoryName, 48 | URL: ctx.ArtifactSources.MetalLB.Repository, 49 | } 50 | 51 | endpointCopierOperatorRepo := image.HelmRepository{ 52 | Name: endpointCopierOperatorRepositoryName, 53 | URL: ctx.ArtifactSources.EndpointCopierOperator.Repository, 54 | } 55 | 56 | repos = append(repos, metallbRepo, endpointCopierOperatorRepo) 57 | } 58 | 59 | return charts, repos 60 | } 61 | -------------------------------------------------------------------------------- /pkg/combustion/keymap.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | ) 14 | 15 | const ( 16 | keymapComponentName = "keymap" 17 | keymapScriptName = "12-keymap-setup.sh" 18 | ) 19 | 20 | //go:embed templates/12-keymap-setup.sh.tpl 21 | var keymapScript string 22 | 23 | func configureKeymap(ctx *image.Context) ([]string, error) { 24 | if err := writeKeymapCombustionScript(ctx); err != nil { 25 | log.AuditComponentFailed(keymapComponentName) 26 | return nil, err 27 | } 28 | 29 | log.AuditComponentSuccessful(keymapComponentName) 30 | return []string{keymapScriptName}, nil 31 | } 32 | 33 | func writeKeymapCombustionScript(ctx *image.Context) error { 34 | keymapScriptFilename := filepath.Join(ctx.CombustionDir, keymapScriptName) 35 | 36 | data, err := template.Parse(keymapScriptName, keymapScript, ctx.ImageDefinition.OperatingSystem) 37 | if err != nil { 38 | return fmt.Errorf("applying template to %s: %w", keymapScriptName, err) 39 | } 40 | 41 | if err := os.WriteFile(keymapScriptFilename, []byte(data), fileio.ExecutablePerms); err != nil { 42 | return fmt.Errorf("writing file %s: %w", keymapScriptFilename, err) 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/combustion/keymap_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureKeymap(t *testing.T) { 15 | // Setup 16 | ctx, teardown := setupContext(t) 17 | defer teardown() 18 | 19 | ctx.ImageDefinition = &image.Definition{ 20 | OperatingSystem: image.OperatingSystem{ 21 | Keymap: "gb", 22 | }, 23 | } 24 | 25 | // Test 26 | scripts, err := configureKeymap(ctx) 27 | 28 | // Verify 29 | require.NoError(t, err) 30 | 31 | require.Len(t, scripts, 1) 32 | assert.Equal(t, keymapScriptName, scripts[0]) 33 | 34 | expectedFilename := filepath.Join(ctx.CombustionDir, keymapScriptName) 35 | foundBytes, err := os.ReadFile(expectedFilename) 36 | require.NoError(t, err) 37 | 38 | stats, err := os.Stat(expectedFilename) 39 | require.NoError(t, err) 40 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 41 | 42 | foundContents := string(foundBytes) 43 | 44 | // - Make sure that the keymap is set correctly 45 | assert.Contains(t, foundContents, "echo \"KEYMAP=gb\" >> /etc/vconsole.conf", "keymap not correctly set") 46 | } 47 | 48 | func TestConfigureKeymap_NoConf(t *testing.T) { 49 | // Setup 50 | ctx, teardown := setupContext(t) 51 | defer teardown() 52 | 53 | ctx.ImageDefinition = &image.Definition{ 54 | OperatingSystem: image.OperatingSystem{}, 55 | } 56 | 57 | // Test 58 | scripts, err := configureKeymap(ctx) 59 | 60 | // Verify 61 | require.NoError(t, err) 62 | 63 | require.Len(t, scripts, 1) 64 | assert.Equal(t, keymapScriptName, scripts[0]) 65 | 66 | expectedFilename := filepath.Join(ctx.CombustionDir, keymapScriptName) 67 | foundBytes, err := os.ReadFile(expectedFilename) 68 | require.NoError(t, err) 69 | 70 | stats, err := os.Stat(expectedFilename) 71 | require.NoError(t, err) 72 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 73 | 74 | foundContents := string(foundBytes) 75 | 76 | // - Make sure that the keymap is set correctly 77 | assert.Contains(t, foundContents, "echo \"KEYMAP=us\" >> /etc/vconsole.conf", "keymap not correctly set") 78 | } 79 | -------------------------------------------------------------------------------- /pkg/combustion/message.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | "github.com/suse-edge/edge-image-builder/pkg/version" 14 | ) 15 | 16 | const ( 17 | messageScriptName = "48-message.sh" 18 | messageComponentName = "identifier" 19 | ) 20 | 21 | //go:embed templates/48-message.sh.tpl 22 | var messageScript string 23 | 24 | func configureMessage(ctx *image.Context) ([]string, error) { 25 | values := struct { 26 | Version string 27 | }{ 28 | Version: version.GetEibVersion(), 29 | } 30 | 31 | data, err := template.Parse(messageScriptName, messageScript, &values) 32 | if err != nil { 33 | return nil, fmt.Errorf("parsing message script template: %w", err) 34 | } 35 | 36 | filename := filepath.Join(ctx.CombustionDir, messageScriptName) 37 | err = os.WriteFile(filename, []byte(data), fileio.ExecutablePerms) 38 | if err != nil { 39 | log.AuditComponentFailed(messageComponentName) 40 | return nil, fmt.Errorf("writing message script: %w", err) 41 | } 42 | 43 | log.AuditComponentSuccessful(messageComponentName) 44 | return []string{messageScriptName}, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/combustion/message_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestConfigureMessage(t *testing.T) { 13 | // Setup 14 | ctx, teardown := setupContext(t) 15 | defer teardown() 16 | 17 | // Test 18 | scripts, err := configureMessage(ctx) 19 | 20 | // Verify 21 | require.NoError(t, err) 22 | 23 | _, err = os.Stat(filepath.Join(ctx.CombustionDir, messageScriptName)) 24 | require.NoError(t, err) 25 | 26 | require.Len(t, scripts, 1) 27 | assert.Equal(t, messageScriptName, scripts[0]) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/combustion/osfiles.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | const ( 16 | osFilesComponentName = "os files" 17 | osFilesConfigDir = "os-files" 18 | osFilesScriptName = "19-copy-os-files.sh" 19 | osFilesLogFile = "copy-os-files.log" 20 | ) 21 | 22 | var ( 23 | //go:embed templates/19-copy-os-files.sh 24 | osFilesScript string 25 | ) 26 | 27 | func configureOSFiles(ctx *image.Context) ([]string, error) { 28 | if !isComponentConfigured(ctx, osFilesConfigDir) { 29 | log.AuditComponentSkipped(osFilesComponentName) 30 | zap.S().Info("skipping os files component, no files provided") 31 | return nil, nil 32 | } 33 | 34 | if err := copyOSFiles(ctx); err != nil { 35 | log.AuditComponentFailed(osFilesComponentName) 36 | return nil, err 37 | } 38 | 39 | if err := writeOSFilesScript(ctx); err != nil { 40 | log.AuditComponentFailed(osFilesComponentName) 41 | return nil, err 42 | } 43 | 44 | log.AuditComponentSuccessful(osFilesComponentName) 45 | return []string{osFilesScriptName}, nil 46 | } 47 | 48 | func copyOSFiles(ctx *image.Context) error { 49 | srcDirectory := filepath.Join(ctx.ImageConfigDir, osFilesConfigDir) 50 | destDirectory := filepath.Join(ctx.CombustionDir, osFilesConfigDir) 51 | 52 | dirEntries, err := os.ReadDir(srcDirectory) 53 | if err != nil { 54 | return fmt.Errorf("reading the os files directory at %s: %w", srcDirectory, err) 55 | } 56 | 57 | // If the directory exists but there's nothing in it, consider it an error case 58 | if len(dirEntries) == 0 { 59 | return fmt.Errorf("no files found in directory %s", srcDirectory) 60 | } 61 | 62 | if err := fileio.CopyFiles(srcDirectory, destDirectory, "", true, nil); err != nil { 63 | return fmt.Errorf("copying os-files: %w", err) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func writeOSFilesScript(ctx *image.Context) error { 70 | osFilesScriptFilename := filepath.Join(ctx.CombustionDir, osFilesScriptName) 71 | 72 | if err := os.WriteFile(osFilesScriptFilename, []byte(osFilesScript), fileio.ExecutablePerms); err != nil { 73 | return fmt.Errorf("writing os files script %s: %w", osFilesScriptFilename, err) 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/combustion/osfiles_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func setupOsFilesConfigDir(t *testing.T, empty bool) (ctx *image.Context, teardown func()) { 15 | ctx, teardown = setupContext(t) 16 | 17 | testOsFilesDir := filepath.Join(ctx.ImageConfigDir, osFilesConfigDir) 18 | err := os.Mkdir(testOsFilesDir, 0o755) 19 | require.NoError(t, err) 20 | 21 | if !empty { 22 | nestedOsFilesDir := filepath.Join(testOsFilesDir, "etc", "ssh") 23 | err = os.MkdirAll(nestedOsFilesDir, 0o755) 24 | require.NoError(t, err) 25 | 26 | testFile := filepath.Join(nestedOsFilesDir, "test-config-file") 27 | _, err = os.Create(testFile) 28 | require.NoError(t, err) 29 | } 30 | 31 | return 32 | } 33 | 34 | func TestConfigureOSFiles(t *testing.T) { 35 | // Setup 36 | ctx, teardown := setupOsFilesConfigDir(t, false) 37 | defer teardown() 38 | 39 | // Test 40 | scriptNames, err := configureOSFiles(ctx) 41 | 42 | // Verify 43 | require.NoError(t, err) 44 | 45 | assert.Equal(t, []string{osFilesScriptName}, scriptNames) 46 | 47 | // -- Combustion Script 48 | expectedCombustionScript := filepath.Join(ctx.CombustionDir, osFilesScriptName) 49 | contents, err := os.ReadFile(expectedCombustionScript) 50 | require.NoError(t, err) 51 | assert.Contains(t, string(contents), "mount /var") 52 | assert.Contains(t, string(contents), "cp -R") 53 | assert.Contains(t, string(contents), "umount /var") 54 | 55 | // -- Files 56 | expectedFile := filepath.Join(ctx.CombustionDir, osFilesConfigDir, "etc", "ssh", "test-config-file") 57 | assert.FileExists(t, expectedFile) 58 | } 59 | 60 | func TestConfigureOSFiles_EmptyDirectory(t *testing.T) { 61 | // Setup 62 | ctx, teardown := setupOsFilesConfigDir(t, true) 63 | defer teardown() 64 | 65 | // Test 66 | scriptName, err := configureOSFiles(ctx) 67 | 68 | // Verify 69 | assert.Nil(t, scriptName) 70 | 71 | srcDirectory := filepath.Join(ctx.ImageConfigDir, osFilesConfigDir) 72 | assert.EqualError(t, err, fmt.Sprintf("no files found in directory %s", srcDirectory)) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/combustion/proxy.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | "github.com/suse-edge/edge-image-builder/pkg/log" 13 | "github.com/suse-edge/edge-image-builder/pkg/template" 14 | ) 15 | 16 | const ( 17 | proxyComponentName = "proxy" 18 | proxyScriptName = "08-proxy-setup.sh" 19 | ) 20 | 21 | //go:embed templates/08-proxy-setup.sh.tpl 22 | var proxyScript string 23 | 24 | func configureProxy(ctx *image.Context) ([]string, error) { 25 | proxy := ctx.ImageDefinition.OperatingSystem.Proxy 26 | if proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" { 27 | log.AuditComponentSkipped(proxyComponentName) 28 | return nil, nil 29 | } 30 | 31 | if err := writeProxyCombustionScript(ctx); err != nil { 32 | log.AuditComponentFailed(proxyComponentName) 33 | return nil, err 34 | } 35 | 36 | log.AuditComponentSuccessful(proxyComponentName) 37 | return []string{proxyScriptName}, nil 38 | } 39 | 40 | func writeProxyCombustionScript(ctx *image.Context) error { 41 | proxyScriptFilename := filepath.Join(ctx.CombustionDir, proxyScriptName) 42 | 43 | values := struct { 44 | HTTPProxy string 45 | HTTPSProxy string 46 | NoProxy string 47 | }{ 48 | HTTPProxy: ctx.ImageDefinition.OperatingSystem.Proxy.HTTPProxy, 49 | HTTPSProxy: ctx.ImageDefinition.OperatingSystem.Proxy.HTTPSProxy, 50 | NoProxy: strings.Join(ctx.ImageDefinition.OperatingSystem.Proxy.NoProxy, ", "), 51 | } 52 | 53 | data, err := template.Parse(proxyScriptName, proxyScript, values) 54 | if err != nil { 55 | return fmt.Errorf("applying template to %s: %w", proxyScriptName, err) 56 | } 57 | 58 | if err := os.WriteFile(proxyScriptFilename, []byte(data), fileio.ExecutablePerms); err != nil { 59 | return fmt.Errorf("writing file %s: %w", proxyScriptFilename, err) 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/combustion/proxy_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureProxy_NoConf(t *testing.T) { 15 | // Setup 16 | var ctx image.Context 17 | 18 | ctx.ImageDefinition = &image.Definition{ 19 | OperatingSystem: image.OperatingSystem{ 20 | Proxy: image.Proxy{}, 21 | }, 22 | } 23 | 24 | // Test 25 | scripts, err := configureProxy(&ctx) 26 | 27 | // Verify 28 | require.NoError(t, err) 29 | assert.Nil(t, scripts) 30 | } 31 | 32 | func TestConfigureProxy_FullConfiguration(t *testing.T) { 33 | // Setup 34 | ctx, teardown := setupContext(t) 35 | defer teardown() 36 | 37 | ctx.ImageDefinition = &image.Definition{ 38 | OperatingSystem: image.OperatingSystem{ 39 | Proxy: image.Proxy{ 40 | HTTPProxy: "http://10.0.0.1:3128", 41 | HTTPSProxy: "http://10.0.0.1:3128", 42 | NoProxy: []string{"localhost", "127.0.0.1", "edge.suse.com"}, 43 | }, 44 | }, 45 | } 46 | 47 | // Test 48 | scripts, err := configureProxy(ctx) 49 | 50 | // Verify 51 | require.NoError(t, err) 52 | 53 | require.Len(t, scripts, 1) 54 | assert.Equal(t, proxyScriptName, scripts[0]) 55 | 56 | expectedFilename := filepath.Join(ctx.CombustionDir, proxyScriptName) 57 | foundBytes, err := os.ReadFile(expectedFilename) 58 | require.NoError(t, err) 59 | 60 | stats, err := os.Stat(expectedFilename) 61 | require.NoError(t, err) 62 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 63 | 64 | foundContents := string(foundBytes) 65 | 66 | // - Make sure that the global PROXY_ENABLED="yes" flag is set because either http/https proxy is set 67 | assert.Contains(t, foundContents, "s|PROXY_ENABLED=.*|PROXY_ENABLED=\"yes\"|g", "global proxy has not been enabled") 68 | 69 | // - Ensure that we have the HTTP_PROXY set correctly 70 | assert.Contains(t, foundContents, "s|HTTP_PROXY=.*|HTTP_PROXY=\"http://10.0.0.1:3128\"|g", "HTTP_PROXY not set correctly") 71 | 72 | // - Ensure that we have the HTTPS_PROXY set correctly 73 | assert.Contains(t, foundContents, "s|HTTPS_PROXY=.*|HTTPS_PROXY=\"http://10.0.0.1:3128\"|g", "HTTPS_PROXY not set correctly") 74 | 75 | // - Ensure that we have the NO_PROXY list overridden 76 | assert.Contains(t, foundContents, "s|NO_PROXY=.*|NO_PROXY=\"localhost, 127.0.0.1, edge.suse.com\"|g", "NO_PROXY not set correctly") 77 | } 78 | -------------------------------------------------------------------------------- /pkg/combustion/script.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "slices" 7 | 8 | "github.com/suse-edge/edge-image-builder/pkg/template" 9 | ) 10 | 11 | //go:embed templates/script-base.sh.tpl 12 | var combustionScriptBase string 13 | 14 | func assembleScript(scripts []string, networkScript string) (string, error) { 15 | slices.Sort(scripts) 16 | 17 | values := struct { 18 | NetworkScript string 19 | Scripts []string 20 | }{ 21 | NetworkScript: networkScript, 22 | Scripts: scripts, 23 | } 24 | 25 | data, err := template.Parse("combustion-base", combustionScriptBase, values) 26 | if err != nil { 27 | return "", fmt.Errorf("parsing combustion base template: %w", err) 28 | } 29 | 30 | return data, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/combustion/script_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestAssembleScript_DynamicNetwork(t *testing.T) { 11 | script, err := assembleScript([]string{"foo.sh", "bar.sh", "baz.sh"}, "") 12 | require.NoError(t, err) 13 | 14 | assert.Contains(t, script, "# combustion: network") 15 | assert.NotContains(t, script, "# combustion: prepare network") 16 | 17 | assert.NotContains(t, script, `if [ "${1-}" = "--prepare" ]; then`) 18 | assert.NotContains(t, script, "./configure-network.sh") 19 | 20 | // alphabetic ordering 21 | assert.Contains(t, script, ` 22 | echo "Running bar.sh" 23 | ./bar.sh 24 | 25 | echo "Running baz.sh" 26 | ./baz.sh 27 | 28 | echo "Running foo.sh" 29 | ./foo.sh 30 | `) 31 | } 32 | 33 | func TestAssembleScript_StaticNetwork(t *testing.T) { 34 | script, err := assembleScript([]string{"foo.sh", "bar.sh", "baz.sh"}, "configure-network.sh") 35 | require.NoError(t, err) 36 | 37 | assert.Contains(t, script, "# combustion: prepare network") 38 | assert.NotContains(t, script, "# combustion: network") 39 | 40 | assert.Contains(t, script, `if [ "${1-}" = "--prepare" ]; then`) 41 | assert.Contains(t, script, "./configure-network.sh") 42 | 43 | // alphabetic ordering 44 | assert.Contains(t, script, ` 45 | echo "Running bar.sh" 46 | ./bar.sh 47 | 48 | echo "Running baz.sh" 49 | ./baz.sh 50 | 51 | echo "Running foo.sh" 52 | ./foo.sh 53 | `) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/combustion/suma.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | ) 14 | 15 | const ( 16 | sumaComponentName = "suma" 17 | sumaScriptName = "30-suma-registration.sh" 18 | ) 19 | 20 | //go:embed templates/30-suma-register.sh.tpl 21 | var sumaScript string 22 | 23 | func configureSuma(ctx *image.Context) ([]string, error) { 24 | suma := ctx.ImageDefinition.OperatingSystem.Suma 25 | if suma.Host == "" { 26 | log.AuditComponentSkipped(sumaComponentName) 27 | return nil, nil 28 | } 29 | 30 | if err := writeSumaCombustionScript(ctx); err != nil { 31 | log.AuditComponentFailed(sumaComponentName) 32 | return nil, err 33 | } 34 | 35 | log.AuditComponentSuccessful(sumaComponentName) 36 | return []string{sumaScriptName}, nil 37 | } 38 | 39 | func writeSumaCombustionScript(ctx *image.Context) error { 40 | sumaScriptFilename := filepath.Join(ctx.CombustionDir, sumaScriptName) 41 | 42 | data, err := template.Parse(sumaScriptName, sumaScript, ctx.ImageDefinition.OperatingSystem.Suma) 43 | if err != nil { 44 | return fmt.Errorf("applying template to %s: %w", sumaScriptName, err) 45 | } 46 | 47 | if err := os.WriteFile(sumaScriptFilename, []byte(data), fileio.ExecutablePerms); err != nil { 48 | return fmt.Errorf("writing file %s: %w", sumaScriptFilename, err) 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/combustion/suma_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureSuma_NoConf(t *testing.T) { 15 | // Setup 16 | ctx, teardown := setupContext(t) 17 | defer teardown() 18 | 19 | ctx.ImageDefinition = &image.Definition{ 20 | OperatingSystem: image.OperatingSystem{ 21 | Suma: image.Suma{}, 22 | }, 23 | } 24 | 25 | // Test 26 | scripts, err := configureSuma(ctx) 27 | 28 | // Verify 29 | require.NoError(t, err) 30 | assert.Nil(t, scripts) 31 | } 32 | 33 | func TestConfigureSuma_FullConfiguration(t *testing.T) { 34 | // Setup 35 | ctx, teardown := setupContext(t) 36 | defer teardown() 37 | 38 | ctx.ImageDefinition = &image.Definition{ 39 | OperatingSystem: image.OperatingSystem{ 40 | Suma: image.Suma{ 41 | Host: "suma.edge.suse.com", 42 | ActivationKey: "slemicro55", 43 | }, 44 | }, 45 | } 46 | 47 | // Test 48 | scripts, err := configureSuma(ctx) 49 | 50 | // Verify 51 | require.NoError(t, err) 52 | 53 | require.Len(t, scripts, 1) 54 | assert.Equal(t, sumaScriptName, scripts[0]) 55 | 56 | expectedFilename := filepath.Join(ctx.CombustionDir, sumaScriptName) 57 | foundBytes, err := os.ReadFile(expectedFilename) 58 | require.NoError(t, err) 59 | 60 | stats, err := os.Stat(expectedFilename) 61 | require.NoError(t, err) 62 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 63 | 64 | foundContents := string(foundBytes) 65 | 66 | // - Ensure that we have the correct URL defined 67 | assert.Contains(t, foundContents, "master: suma.edge.suse.com") 68 | 69 | // - Ensure that we've got the activation key defined 70 | assert.Contains(t, foundContents, "activation_key: \"slemicro55\"") 71 | } 72 | -------------------------------------------------------------------------------- /pkg/combustion/systemd.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | ) 14 | 15 | const ( 16 | systemdComponentName = "systemd" 17 | systemdScriptName = "14-systemd.sh" 18 | ) 19 | 20 | //go:embed templates/14-systemd.sh.tpl 21 | var systemdTemplate string 22 | 23 | func configureSystemd(ctx *image.Context) ([]string, error) { 24 | // Nothing to do if both lists are empty 25 | systemd := ctx.ImageDefinition.OperatingSystem.Systemd 26 | if len(systemd.Enable) == 0 && len(systemd.Disable) == 0 { 27 | log.AuditComponentSkipped(systemdComponentName) 28 | return nil, nil 29 | } 30 | 31 | data, err := template.Parse(systemdScriptName, systemdTemplate, ctx.ImageDefinition.OperatingSystem.Systemd) 32 | if err != nil { 33 | log.AuditComponentFailed(systemdComponentName) 34 | return nil, fmt.Errorf("applying systemd script template: %w", err) 35 | } 36 | 37 | filename := filepath.Join(ctx.CombustionDir, systemdScriptName) 38 | err = os.WriteFile(filename, []byte(data), fileio.ExecutablePerms) 39 | if err != nil { 40 | log.AuditComponentFailed(systemdComponentName) 41 | return nil, fmt.Errorf("writing systemd combustion file: %w", err) 42 | } 43 | 44 | log.AuditComponentSuccessful(systemdComponentName) 45 | return []string{systemdScriptName}, nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/combustion/systemd_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureSystemd_NoServices(t *testing.T) { 15 | // Setup 16 | ctx, teardown := setupContext(t) 17 | defer teardown() 18 | 19 | ctx.ImageDefinition = &image.Definition{ 20 | OperatingSystem: image.OperatingSystem{ 21 | Systemd: image.Systemd{}, 22 | }, 23 | } 24 | 25 | // Test 26 | scripts, err := configureSystemd(ctx) 27 | 28 | // Verify 29 | require.NoError(t, err) 30 | assert.Nil(t, scripts) 31 | } 32 | 33 | func TestConfigureSystemd_BothServiceTypes(t *testing.T) { 34 | // Setup 35 | ctx, teardown := setupContext(t) 36 | defer teardown() 37 | 38 | ctx.ImageDefinition = &image.Definition{ 39 | OperatingSystem: image.OperatingSystem{ 40 | Systemd: image.Systemd{ 41 | Enable: []string{"enable0"}, 42 | Disable: []string{"disable0", "disable1"}, 43 | }, 44 | }, 45 | } 46 | 47 | // Test 48 | scripts, err := configureSystemd(ctx) 49 | 50 | // Verify 51 | require.NoError(t, err) 52 | 53 | require.Len(t, scripts, 1) 54 | assert.Equal(t, systemdScriptName, scripts[0]) 55 | 56 | expectedFilename := filepath.Join(ctx.CombustionDir, systemdScriptName) 57 | foundBytes, err := os.ReadFile(expectedFilename) 58 | require.NoError(t, err) 59 | 60 | stats, err := os.Stat(expectedFilename) 61 | require.NoError(t, err) 62 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 63 | 64 | foundContents := string(foundBytes) 65 | 66 | // - Enabled services 67 | assert.Contains(t, foundContents, "systemctl enable enable0") 68 | 69 | // - Disabled services 70 | assert.Contains(t, foundContents, "systemctl disable disable0") 71 | assert.Contains(t, foundContents, "systemctl mask disable0") 72 | assert.Contains(t, foundContents, "systemctl disable disable1") 73 | assert.Contains(t, foundContents, "systemctl mask disable1") 74 | } 75 | -------------------------------------------------------------------------------- /pkg/combustion/templates/05-configure-network.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Use "|| true" in order to allow for DHCP configurations in cases where nmc fails 5 | ./nmc apply --config-dir {{ .ConfigDir }} || true 6 | -------------------------------------------------------------------------------- /pkg/combustion/templates/07-certificates.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | cp ./{{ .CertificatesDir }}/* /etc/pki/trust/anchors/. 5 | update-ca-certificates -v 6 | -------------------------------------------------------------------------------- /pkg/combustion/templates/08-proxy-setup.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | {{ if or ( .HTTPProxy ) ( .HTTPSProxy ) }} 5 | sed -i 's|PROXY_ENABLED=.*|PROXY_ENABLED="yes"|g' /etc/sysconfig/proxy 6 | {{ end -}} 7 | 8 | {{ if .HTTPProxy -}} 9 | sed -i 's|HTTP_PROXY=.*|HTTP_PROXY="{{ .HTTPProxy }}"|g' /etc/sysconfig/proxy 10 | {{ end -}} 11 | 12 | {{ if .HTTPSProxy -}} 13 | sed -i 's|HTTPS_PROXY=.*|HTTPS_PROXY="{{ .HTTPSProxy }}"|g' /etc/sysconfig/proxy 14 | {{ end -}} 15 | 16 | {{ if .NoProxy -}} 17 | sed -i 's|NO_PROXY=.*|NO_PROXY="{{ .NoProxy }}"|g' /etc/sysconfig/proxy 18 | {{ end -}} 19 | 20 | -------------------------------------------------------------------------------- /pkg/combustion/templates/10-rpm-install.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | {{/* Template Fields */ -}} 5 | {{/* RepoPath - path to the air-gapped repository that was created by the RPM resolver */ -}} 6 | {{/* RepoName - name of the air-gapped repository that was created by the RPM resolver */ -}} 7 | {{/* PKGList - list of packages that will be installed */ -}} 8 | 9 | zypper ar file://{{.RepoPath}}/{{.RepoName}} {{.RepoName}} 10 | zypper --no-gpg-checks install -r {{.RepoName}} -y --force-resolution --auto-agree-with-licenses {{.PKGList}} 11 | zypper rr {{.RepoName}} 12 | -------------------------------------------------------------------------------- /pkg/combustion/templates/11-time-setup.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | {{ if .Timezone -}} 5 | ln -sf /usr/share/zoneinfo/{{ .Timezone }} /etc/localtime 6 | {{ end -}} 7 | 8 | {{ if or (gt (len .Pools) 0) (gt (len .Servers) 0) }} 9 | rm -f /etc/chrony.d/pool.conf 10 | {{ end -}} 11 | 12 | {{ range .Pools -}} 13 | echo "pool {{ . }} iburst" >> /etc/chrony.d/eib-sources.conf 14 | {{ end -}} 15 | 16 | {{ range .Servers -}} 17 | echo "server {{ . }} iburst" >> /etc/chrony.d/eib-sources.conf 18 | {{ end -}} 19 | 20 | {{ if .ForceWait -}} 21 | # Create a simple systemd OneShot service that depends on networking and chrony-wait 22 | # (a service that forces a synchronisation of local time with the available NTP sources 23 | # but has a 180s timeout) and one that must complete before k3s/rke2 start. This temporary 24 | # systemd unit enables us to wait on the chrony sync *without* modifying the chrony-wait 25 | # service or the default k3s/rke2 systemd unit files. The systemd unit file needs to 26 | # execute something, so we echo out to syslog that we've either reached the timeout, or 27 | # the synchronisation was completed successfully, whichever comes first. 28 | cat </etc/systemd/system/firstboot-timesync.service 29 | [Unit] 30 | Description=Attempt NTP timesync to occur before starting Kubernetes services 31 | Requires=chronyd.service 32 | Wants=network-online.target 33 | After=network-online.target 34 | After=chrony-wait.service 35 | Before=rke2-server.service 36 | Before=rke2-agent.service 37 | Before=k3s.service 38 | 39 | [Service] 40 | User=root 41 | Type=oneshot 42 | ExecStart=/usr/bin/echo "[INFO] Either reached 180s timeout or was successful in timesync before starting system services." 43 | RemainAfterExit=true 44 | 45 | [Install] 46 | WantedBy=multi-user.target 47 | EOF 48 | 49 | systemctl enable chrony-wait 50 | systemctl enable firstboot-timesync.service 51 | 52 | # Print to the console that we're pausing boot whilst the chrony-wait service executes. 53 | # If this happens immediately then this will likely skip by, but if NTP is unavailable 54 | # then it makes it clear to the user why the system is pausing. 55 | echo "[WARN]: Waiting up to 180s to synchronise system clock with available NTP sources." 56 | {{ end -}} 57 | -------------------------------------------------------------------------------- /pkg/combustion/templates/12-keymap-setup.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | echo "KEYMAP={{ or .Keymap "us" }}" >> /etc/vconsole.conf 5 | -------------------------------------------------------------------------------- /pkg/combustion/templates/13a-add-groups.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | {{- range . }} 5 | {{- $gid := "" }} 6 | {{- if (ne .GID 0 )}} 7 | {{- $gid = (printf "-g %v " .GID) }} 8 | {{- end }} 9 | groupadd -f {{ $gid }}{{ .Name }} 10 | {{- end }} -------------------------------------------------------------------------------- /pkg/combustion/templates/13b-add-users.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Without this, the script will run successfully during combustion, but when /home 5 | # is mounted it will hide the /home used during these user creations. 6 | mount /home 7 | #--- 8 | {{- range $user := . }} 9 | {{- /* Non-root users */}} 10 | {{- if (ne $user.Username "root") }} 11 | {{- $create_home := ""}} 12 | {{- if $user.CreateHomeDir }} 13 | {{- $create_home = "-m "}} 14 | {{- end }} 15 | {{- $uid := ""}} 16 | {{- if (ne $user.UID 0)}} 17 | {{- $uid = (printf "-u %v " $user.UID)}} 18 | {{- end }} 19 | {{- $primary_group := ""}} 20 | {{- if $user.PrimaryGroup }} 21 | {{- $primary_group = (printf "-g %v " $user.PrimaryGroup) }} 22 | {{- end }} 23 | {{- $secondary_groups := ""}} 24 | {{- if $user.SecondaryGroups }} 25 | {{- $secondary_groups = (printf "-G %v " (join $user.SecondaryGroups ",")) }} 26 | {{- end }} 27 | useradd {{ $create_home }}{{ $uid }}{{ $primary_group }}{{ $secondary_groups }}{{$user.Username}} 28 | 29 | {{- if $user.EncryptedPassword }} 30 | echo '{{$user.Username}}:{{$user.EncryptedPassword}}' | chpasswd -e 31 | {{- end }} 32 | 33 | {{- range $user.SSHKeys }} 34 | mkdir -pm700 /home/{{$user.Username}}/.ssh/ 35 | echo '{{.}}' >> /home/{{$user.Username}}/.ssh/authorized_keys 36 | chown -R {{$user.Username}} /home/{{$user.Username}}/.ssh 37 | {{- end }} 38 | # --- 39 | {{- else }} 40 | 41 | {{- /* Root user */}} 42 | {{- if $user.EncryptedPassword }} 43 | echo '{{$user.Username}}:{{$user.EncryptedPassword}}' | chpasswd -e 44 | {{- end }} 45 | 46 | {{- range $user.SSHKeys }} 47 | mkdir -pm700 /{{$user.Username}}/.ssh/ 48 | echo '{{.}}' >> /{{$user.Username}}/.ssh/authorized_keys 49 | {{- end }} 50 | # --- 51 | {{- end }} 52 | 53 | {{- end }} 54 | 55 | umount /home 56 | -------------------------------------------------------------------------------- /pkg/combustion/templates/14-systemd.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | {{ range .Disable }} 5 | systemctl disable {{ . }} 6 | systemctl mask {{ . }} 7 | {{ end }} 8 | 9 | {{ range .Enable }} 10 | systemctl enable {{ . }} 11 | {{ end }} -------------------------------------------------------------------------------- /pkg/combustion/templates/15-fips-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | fips-mode-setup --enable 5 | -------------------------------------------------------------------------------- /pkg/combustion/templates/19-copy-os-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mount /var 5 | cp -R ./os-files/* / 6 | umount /var -------------------------------------------------------------------------------- /pkg/combustion/templates/26-embedded-registry.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mkdir -p /opt/hauler 5 | cp {{ .RegistryDir }}/hauler /opt/hauler/hauler 6 | cp {{ .RegistryDir }}/*-{{ .RegistryTarSuffix }} /opt/hauler/ 7 | 8 | cat <<- EOF > /etc/systemd/system/eib-embedded-registry.service 9 | [Unit] 10 | Description=Load and Serve Embedded Registry 11 | After=network.target 12 | 13 | [Service] 14 | Type=simple 15 | User=root 16 | WorkingDirectory=/opt/hauler 17 | ExecStartPre=/bin/bash -c "for file in /opt/hauler/*-{{ .RegistryTarSuffix }}; do [ -f \"\$file\" ] && /opt/hauler/hauler store load -f \"\$file\" --tempdir /opt/hauler; done" 18 | ExecStart=/opt/hauler/hauler store serve registry -p {{ .RegistryPort }} 19 | Restart=on-failure 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | EOF 24 | 25 | systemctl enable eib-embedded-registry.service 26 | -------------------------------------------------------------------------------- /pkg/combustion/templates/30-suma-register.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mkdir -p /etc/venv-salt-minion/ 5 | 6 | cat < /etc/venv-salt-minion/minion 7 | master: {{ .Host }} 8 | 9 | grains: 10 | susemanager: 11 | activation_key: "{{ .ActivationKey }}" 12 | 13 | server_id_use_crc: adler32 14 | enable_legacy_startup_events: False 15 | enable_fqdns_grains: False 16 | 17 | EOF 18 | 19 | systemctl enable venv-salt-minion || true 20 | -------------------------------------------------------------------------------- /pkg/combustion/templates/48-message.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | echo "Configured with the Edge Image Builder {{ .Version }}" >> /etc/issue.d/eib 4 | -------------------------------------------------------------------------------- /pkg/combustion/templates/cleanup-combustion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | rm -r /combustion 5 | 6 | if test -d /artefacts; then 7 | rm -r /artefacts 8 | fi -------------------------------------------------------------------------------- /pkg/combustion/templates/k3s-multi-node-installer.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | declare -A hosts 5 | 6 | {{- range .nodes }} 7 | hosts[{{ .Hostname }}]={{ .Type }} 8 | {{- end }} 9 | 10 | HOSTNAME=$(cat /etc/hostname) 11 | if [ ! "$HOSTNAME" ]; then 12 | HOSTNAME=$(cat /proc/sys/kernel/hostname) 13 | if [ ! "$HOSTNAME" ] || [ "$HOSTNAME" = "localhost.localdomain" ]; then 14 | echo "ERROR: Could not identify whether the host is a k3s server or agent due to missing hostname" 15 | exit 1 16 | fi 17 | fi 18 | 19 | NODETYPE="${hosts[$HOSTNAME]:-none}" 20 | if [ "$NODETYPE" = "none" ]; then 21 | echo "ERROR: Could not identify whether host '$HOSTNAME' is a k3s server or agent" 22 | exit 1 23 | fi 24 | 25 | mount /var 26 | 27 | mkdir -p /var/lib/rancher/k3s/agent/images/ 28 | cp {{ .imagesPath }}/* /var/lib/rancher/k3s/agent/images/ 29 | 30 | umount /var 31 | 32 | CONFIGFILE={{ .configFilePath }}/$NODETYPE.yaml 33 | 34 | if [ "$HOSTNAME" = {{ .initialiser }} ]; then 35 | CONFIGFILE={{ .configFilePath }}/{{ .initialiserConfigFile }} 36 | 37 | {{ if .manifestsPath }} 38 | mkdir -p /opt/eib-k8s/manifests 39 | cp {{ .manifestsPath }}/* /opt/eib-k8s/manifests/ 40 | 41 | cat <<- 'EOF' > /opt/eib-k8s/create_manifests.sh 42 | #!/bin/bash 43 | failed=false 44 | 45 | for file in /opt/eib-k8s/manifests/*; do 46 | output=$(/opt/bin/kubectl create -f "$file" --kubeconfig=/etc/rancher/k3s/k3s.yaml 2>&1) 47 | 48 | if [ $? != 0 ]; then 49 | while IFS= read -r line; do 50 | if [[ "$line" != *"AlreadyExists"* ]]; then 51 | failed=true 52 | fi 53 | done <<< "$output" 54 | fi 55 | echo "$output" 56 | done 57 | 58 | if [ $failed = "true" ]; then 59 | exit 1 60 | fi 61 | EOF 62 | 63 | chmod +x /opt/eib-k8s/create_manifests.sh 64 | 65 | cat <<- EOF > /etc/systemd/system/kubernetes-resources-install.service 66 | [Unit] 67 | Description=Kubernetes Resources Install 68 | Requires=k3s.service 69 | After=k3s.service 70 | ConditionPathExists=/opt/bin/kubectl 71 | ConditionPathExists=/etc/rancher/k3s/k3s.yaml 72 | 73 | [Install] 74 | WantedBy=multi-user.target 75 | 76 | [Service] 77 | Type=oneshot 78 | Restart=on-failure 79 | RestartSec=60 80 | ExecStartPre=/bin/sh -c 'until [ "\$(systemctl show -p SubState --value k3s.service)" = "running" ]; do sleep 10; done' 81 | ExecStart=/opt/eib-k8s/create_manifests.sh 82 | # Disable the service and clean up 83 | ExecStartPost=/bin/sh -c "systemctl disable kubernetes-resources-install.service" 84 | ExecStartPost=rm -f /etc/systemd/system/kubernetes-resources-install.service 85 | ExecStartPost=rm -rf /opt/eib-k8s 86 | EOF 87 | 88 | systemctl enable kubernetes-resources-install.service 89 | {{- end }} 90 | fi 91 | 92 | {{- if and .apiVIP4 .apiHost }} 93 | echo "{{ .apiVIP4 }} {{ .apiHost }}" >> /etc/hosts 94 | {{- end }} 95 | 96 | {{- if and .apiVIP6 .apiHost }} 97 | echo "{{ .apiVIP6 }} {{ .apiHost }}" >> /etc/hosts 98 | {{- end }} 99 | 100 | mkdir -p /etc/rancher/k3s/ 101 | cp $CONFIGFILE /etc/rancher/k3s/config.yaml 102 | 103 | {{- if .setNodeIPScript }} 104 | if [ "$NODETYPE" = "server" ]; then 105 | sh {{ .setNodeIPScript }} 106 | fi 107 | {{- end }} 108 | 109 | if [ -f {{ .registryMirrors }} ]; then 110 | cp {{ .registryMirrors }} /etc/rancher/k3s/registries.yaml 111 | fi 112 | 113 | export INSTALL_K3S_EXEC=$NODETYPE 114 | export INSTALL_K3S_SKIP_DOWNLOAD=true 115 | export INSTALL_K3S_SKIP_START=true 116 | export INSTALL_K3S_BIN_DIR=/opt/bin 117 | 118 | mkdir -p $INSTALL_K3S_BIN_DIR 119 | cp {{ .binaryPath }} $INSTALL_K3S_BIN_DIR/k3s 120 | chmod +x $INSTALL_K3S_BIN_DIR/k3s 121 | 122 | sh {{ .installScript }} 123 | -------------------------------------------------------------------------------- /pkg/combustion/templates/k3s-single-node-installer.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mount /var 5 | 6 | mkdir -p /var/lib/rancher/k3s/agent/images/ 7 | cp {{ .imagesPath }}/* /var/lib/rancher/k3s/agent/images/ 8 | 9 | umount /var 10 | 11 | {{- if .manifestsPath }} 12 | mkdir -p /opt/eib-k8s/manifests 13 | cp {{ .manifestsPath }}/* /opt/eib-k8s/manifests/ 14 | 15 | cat <<- 'EOF' > /opt/eib-k8s/create_manifests.sh 16 | #!/bin/bash 17 | failed=false 18 | 19 | for file in /opt/eib-k8s/manifests/*; do 20 | output=$(/opt/bin/kubectl create -f "$file" --kubeconfig=/etc/rancher/k3s/k3s.yaml 2>&1) 21 | 22 | if [ $? != 0 ]; then 23 | while IFS= read -r line; do 24 | if [[ "$line" != *"AlreadyExists"* ]]; then 25 | failed=true 26 | fi 27 | done <<< "$output" 28 | fi 29 | echo "$output" 30 | done 31 | 32 | if [ $failed = "true" ]; then 33 | exit 1 34 | fi 35 | EOF 36 | 37 | chmod +x /opt/eib-k8s/create_manifests.sh 38 | 39 | cat <<- EOF > /etc/systemd/system/kubernetes-resources-install.service 40 | [Unit] 41 | Description=Kubernetes Resources Install 42 | Requires=k3s.service 43 | After=k3s.service 44 | ConditionPathExists=/opt/bin/kubectl 45 | ConditionPathExists=/etc/rancher/k3s/k3s.yaml 46 | 47 | [Install] 48 | WantedBy=multi-user.target 49 | 50 | [Service] 51 | Type=oneshot 52 | Restart=on-failure 53 | RestartSec=60 54 | ExecStartPre=/bin/sh -c 'until [ "\$(systemctl show -p SubState --value k3s.service)" = "running" ]; do sleep 10; done' 55 | ExecStart=/opt/eib-k8s/create_manifests.sh 56 | # Disable the service and clean up 57 | ExecStartPost=/bin/sh -c "systemctl disable kubernetes-resources-install.service" 58 | ExecStartPost=rm -f /etc/systemd/system/kubernetes-resources-install.service 59 | ExecStartPost=rm -rf /opt/eib-k8s 60 | EOF 61 | 62 | systemctl enable kubernetes-resources-install.service 63 | {{- end }} 64 | 65 | {{- if and .apiVIP4 .apiHost }} 66 | echo "{{ .apiVIP4 }} {{ .apiHost }}" >> /etc/hosts 67 | {{- end }} 68 | 69 | {{- if and .apiVIP6 .apiHost }} 70 | echo "{{ .apiVIP6 }} {{ .apiHost }}" >> /etc/hosts 71 | {{- end }} 72 | 73 | mkdir -p /etc/rancher/k3s/ 74 | cp {{ .configFilePath }}/{{ .configFile }} /etc/rancher/k3s/config.yaml 75 | 76 | {{- if .setNodeIPScript }} 77 | sh {{ .setNodeIPScript }} 78 | {{- end }} 79 | 80 | if [ -f {{ .registryMirrors }} ]; then 81 | cp {{ .registryMirrors }} /etc/rancher/k3s/registries.yaml 82 | fi 83 | 84 | export INSTALL_K3S_SKIP_DOWNLOAD=true 85 | export INSTALL_K3S_SKIP_START=true 86 | export INSTALL_K3S_BIN_DIR=/opt/bin 87 | 88 | mkdir -p $INSTALL_K3S_BIN_DIR 89 | cp {{ .binaryPath }} $INSTALL_K3S_BIN_DIR/k3s 90 | chmod +x $INSTALL_K3S_BIN_DIR/k3s 91 | 92 | sh {{ .installScript }} 93 | -------------------------------------------------------------------------------- /pkg/combustion/templates/k8s-vip.yaml.tpl: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: metallb.io/v1beta1 3 | kind: IPAddressPool 4 | metadata: 5 | name: api-ip 6 | namespace: metallb-system 7 | spec: 8 | addresses: 9 | {{- if .APIAddress4 }} 10 | - {{ .APIAddress4 }}/32 11 | {{- end }} 12 | {{- if .APIAddress6 }} 13 | - {{ .APIAddress6 }}/128 14 | {{- end }} 15 | avoidBuggyIPs: true 16 | serviceAllocation: 17 | namespaces: 18 | - default 19 | serviceSelectors: 20 | - matchExpressions: 21 | - {key: "serviceType", operator: In, values: [kubernetes-vip]} 22 | --- 23 | apiVersion: metallb.io/v1beta1 24 | kind: L2Advertisement 25 | metadata: 26 | name: api-ip-l2-adv 27 | namespace: metallb-system 28 | spec: 29 | ipAddressPools: 30 | - api-ip 31 | --- 32 | apiVersion: v1 33 | kind: Service 34 | metadata: 35 | name: kubernetes-vip 36 | namespace: default 37 | labels: 38 | serviceType: kubernetes-vip 39 | spec: 40 | {{- if and .APIAddress4 .APIAddress6 }} 41 | ipFamilyPolicy: RequireDualStack 42 | ipFamilies: 43 | - IPv4 44 | - IPv6 45 | {{- else if .APIAddress6 }} 46 | ipFamilyPolicy: SingleStack 47 | ipFamilies: 48 | - IPv6 49 | {{- end }} 50 | ports: 51 | {{- if .RKE2 }} 52 | - name: rke2-api 53 | port: 9345 54 | protocol: TCP 55 | targetPort: 9345 56 | {{- end }} 57 | - name: k8s-api 58 | port: 6443 59 | protocol: TCP 60 | targetPort: 6443 61 | type: LoadBalancer 62 | -------------------------------------------------------------------------------- /pkg/combustion/templates/registries.yaml.tpl: -------------------------------------------------------------------------------- 1 | mirrors: 2 | docker.io: 3 | endpoint: 4 | - "http://localhost:{{ .Port }}" 5 | {{- range .Hostnames }} 6 | {{ . }}: 7 | endpoint: 8 | - "http://localhost:{{ $.Port }}" 9 | {{- end }} -------------------------------------------------------------------------------- /pkg/combustion/templates/rke2-single-node-installer.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mount /var 5 | 6 | mkdir -p /var/lib/rancher/rke2/agent/images/ 7 | cp {{ .imagesPath }}/* /var/lib/rancher/rke2/agent/images/ 8 | 9 | umount /var 10 | 11 | {{- if .manifestsPath }} 12 | mkdir -p /opt/eib-k8s/manifests 13 | cp {{ .manifestsPath }}/* /opt/eib-k8s/manifests/ 14 | 15 | cat <<- 'EOF' > /opt/eib-k8s/create_manifests.sh 16 | #!/bin/bash 17 | failed=false 18 | 19 | for file in /opt/eib-k8s/manifests/*; do 20 | output=$(/opt/eib-k8s/kubectl create -f "$file" --kubeconfig /etc/rancher/rke2/rke2.yaml 2>&1) 21 | 22 | if [ $? != 0 ]; then 23 | while IFS= read -r line; do 24 | if [[ "$line" != *"AlreadyExists"* ]]; then 25 | failed=true 26 | fi 27 | done <<< "$output" 28 | fi 29 | echo "$output" 30 | done 31 | 32 | if [ $failed = "true" ]; then 33 | exit 1 34 | fi 35 | EOF 36 | 37 | chmod +x /opt/eib-k8s/create_manifests.sh 38 | 39 | cat <<- EOF > /etc/systemd/system/kubernetes-resources-install.service 40 | [Unit] 41 | Description=Kubernetes Resources Install 42 | Requires=rke2-server.service 43 | After=rke2-server.service 44 | ConditionPathExists=/var/lib/rancher/rke2/bin/kubectl 45 | ConditionPathExists=/etc/rancher/rke2/rke2.yaml 46 | 47 | [Install] 48 | WantedBy=multi-user.target 49 | 50 | [Service] 51 | Type=oneshot 52 | Restart=on-failure 53 | RestartSec=60 54 | # Copy kubectl in order to avoid SELinux permission issues 55 | ExecStartPre=/bin/sh -c 'until [ "\$(systemctl show -p SubState --value rke2-server.service)" = "running" ]; do sleep 10; done' 56 | ExecStartPre=cp /var/lib/rancher/rke2/bin/kubectl /opt/eib-k8s/kubectl 57 | ExecStart=/opt/eib-k8s/create_manifests.sh 58 | # Disable the service and clean up 59 | ExecStartPost=/bin/sh -c "systemctl disable kubernetes-resources-install.service" 60 | ExecStartPost=rm -f /etc/systemd/system/kubernetes-resources-install.service 61 | ExecStartPost=rm -rf /opt/eib-k8s 62 | EOF 63 | 64 | systemctl enable kubernetes-resources-install.service 65 | {{- end }} 66 | 67 | {{- if and .apiVIP4 .apiHost }} 68 | echo "{{ .apiVIP4 }} {{ .apiHost }}" >> /etc/hosts 69 | {{- end }} 70 | 71 | {{- if and .apiVIP6 .apiHost }} 72 | echo "{{ .apiVIP6 }} {{ .apiHost }}" >> /etc/hosts 73 | {{- end }} 74 | 75 | mkdir -p /etc/rancher/rke2/ 76 | cp {{ .configFilePath }}/{{ .configFile }} /etc/rancher/rke2/config.yaml 77 | 78 | {{- if .setNodeIPScript }} 79 | sh {{ .setNodeIPScript }} 80 | {{- end }} 81 | 82 | if [ -f {{ .registryMirrors }} ]; then 83 | cp {{ .registryMirrors }} /etc/rancher/rke2/registries.yaml 84 | fi 85 | 86 | export INSTALL_RKE2_TAR_PREFIX=/opt/rke2 87 | export INSTALL_RKE2_ARTIFACT_PATH={{ .installPath }} 88 | 89 | # Create the CNI directory, usually created and labelled by the 90 | # rke2-selinux package, but isn't executed during combustion. 91 | mkdir -p /opt/cni 92 | 93 | sh {{ .installScript }} 94 | 95 | systemctl enable rke2-server.service 96 | -------------------------------------------------------------------------------- /pkg/combustion/templates/script-base.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | {{ if .NetworkScript -}} 5 | # combustion: prepare network 6 | 7 | if [ "${1-}" = "--prepare" ]; then 8 | ./{{ .NetworkScript }} 9 | exit 0 10 | fi 11 | {{- else -}} 12 | # combustion: network 13 | {{- end }} 14 | 15 | # Redirect output to the console 16 | exec > >(exec tee -a /dev/tty0) 2>&1 17 | 18 | cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 19 | 20 | mount -o ro /dev/disk/by-label/INSTALL /mnt 21 | export ARTEFACTS_DIR=/mnt/artefacts 22 | 23 | {{ range .Scripts -}} 24 | echo "Running {{ . }}" 25 | ./{{ . }} 26 | 27 | {{ end -}} 28 | 29 | umount /mnt 30 | -------------------------------------------------------------------------------- /pkg/combustion/time.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | ) 14 | 15 | const ( 16 | timeComponentName = "time" 17 | timeScriptName = "11-time-setup.sh" 18 | ) 19 | 20 | //go:embed templates/11-time-setup.sh.tpl 21 | var timeScript string 22 | 23 | func configureTime(ctx *image.Context) ([]string, error) { 24 | time := ctx.ImageDefinition.OperatingSystem.Time 25 | if time.Timezone == "" { 26 | log.AuditComponentSkipped(timeComponentName) 27 | return nil, nil 28 | } 29 | 30 | if err := writeTimeCombustionScript(ctx); err != nil { 31 | log.AuditComponentFailed(timeComponentName) 32 | return nil, err 33 | } 34 | 35 | log.AuditComponentSuccessful(timeComponentName) 36 | return []string{timeScriptName}, nil 37 | } 38 | 39 | func writeTimeCombustionScript(ctx *image.Context) error { 40 | timeScriptFilename := filepath.Join(ctx.CombustionDir, timeScriptName) 41 | 42 | values := struct { 43 | Timezone string 44 | Pools []string 45 | Servers []string 46 | ForceWait bool 47 | }{ 48 | Timezone: ctx.ImageDefinition.OperatingSystem.Time.Timezone, 49 | Pools: ctx.ImageDefinition.OperatingSystem.Time.NtpConfiguration.Pools, 50 | Servers: ctx.ImageDefinition.OperatingSystem.Time.NtpConfiguration.Servers, 51 | ForceWait: ctx.ImageDefinition.OperatingSystem.Time.NtpConfiguration.ForceWait, 52 | } 53 | 54 | data, err := template.Parse(timeScriptName, timeScript, values) 55 | if err != nil { 56 | return fmt.Errorf("applying template to %s: %w", timeScriptName, err) 57 | } 58 | 59 | if err := os.WriteFile(timeScriptFilename, []byte(data), fileio.ExecutablePerms); err != nil { 60 | return fmt.Errorf("writing file %s: %w", timeScriptFilename, err) 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/combustion/time_test.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestConfigureTime_NoConf(t *testing.T) { 15 | // Setup 16 | var ctx image.Context 17 | 18 | ctx.ImageDefinition = &image.Definition{ 19 | OperatingSystem: image.OperatingSystem{ 20 | Time: image.Time{}, 21 | }, 22 | } 23 | 24 | // Test 25 | scripts, err := configureTime(&ctx) 26 | 27 | // Verify 28 | require.NoError(t, err) 29 | assert.Nil(t, scripts) 30 | } 31 | 32 | func TestConfigureTime_FullConfiguration(t *testing.T) { 33 | // Setup 34 | ctx, teardown := setupContext(t) 35 | defer teardown() 36 | 37 | ctx.ImageDefinition = &image.Definition{ 38 | OperatingSystem: image.OperatingSystem{ 39 | Time: image.Time{ 40 | Timezone: "Europe/London", 41 | NtpConfiguration: image.NtpConfiguration{ 42 | Pools: []string{"2.suse.pool.ntp.org"}, 43 | Servers: []string{"10.0.0.1", "10.0.0.2"}, 44 | ForceWait: true, 45 | }, 46 | }, 47 | }, 48 | } 49 | 50 | // Test 51 | scripts, err := configureTime(ctx) 52 | 53 | // Verify 54 | require.NoError(t, err) 55 | 56 | require.Len(t, scripts, 1) 57 | assert.Equal(t, timeScriptName, scripts[0]) 58 | 59 | expectedFilename := filepath.Join(ctx.CombustionDir, timeScriptName) 60 | foundBytes, err := os.ReadFile(expectedFilename) 61 | require.NoError(t, err) 62 | 63 | stats, err := os.Stat(expectedFilename) 64 | require.NoError(t, err) 65 | assert.Equal(t, fileio.ExecutablePerms, stats.Mode()) 66 | 67 | foundContents := string(foundBytes) 68 | 69 | // - Make sure that the symbolic link is created with correct timezone 70 | assert.Contains(t, foundContents, "ln -sf /usr/share/zoneinfo/Europe/London /etc/localtime", "symbolic link not created") 71 | 72 | // - Ensure that we have the correct chrony pool listed in chrony sources 73 | assert.Contains(t, foundContents, "pool 2.suse.pool.ntp.org iburst", "chrony pool not created") 74 | 75 | // - Ensure that we have the correct first chrony server listed in chrony sources 76 | assert.Contains(t, foundContents, "server 10.0.0.1 iburst", "first chronyServer not created") 77 | 78 | // - Ensure that we have the correct second chrony server listed in chrony sources 79 | assert.Contains(t, foundContents, "server 10.0.0.1 iburst", "second chronyServer not created") 80 | 81 | // - Ensure that we're creating the firstboot-timesync service 82 | assert.Contains(t, foundContents, "/etc/systemd/system/firstboot-timesync.service") 83 | 84 | // - Ensure that we've got the chrony-wait service starting at boot 85 | assert.Contains(t, foundContents, "systemctl enable chrony-wait") 86 | } 87 | -------------------------------------------------------------------------------- /pkg/combustion/users.go: -------------------------------------------------------------------------------- 1 | package combustion 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | "github.com/suse-edge/edge-image-builder/pkg/template" 13 | ) 14 | 15 | const ( 16 | usersScriptName = "13b-add-users.sh" 17 | usersComponentName = "users" 18 | ) 19 | 20 | //go:embed templates/13b-add-users.sh.tpl 21 | var usersScript string 22 | 23 | func configureUsers(ctx *image.Context) ([]string, error) { 24 | // Punch out early if there are no users 25 | if len(ctx.ImageDefinition.OperatingSystem.Users) == 0 { 26 | log.AuditComponentSkipped(usersComponentName) 27 | return nil, nil 28 | } 29 | 30 | data, err := template.Parse(usersScriptName, usersScript, ctx.ImageDefinition.OperatingSystem.Users) 31 | if err != nil { 32 | log.AuditComponentFailed(usersComponentName) 33 | return nil, fmt.Errorf("parsing users script template: %w", err) 34 | } 35 | 36 | filename := filepath.Join(ctx.CombustionDir, usersScriptName) 37 | err = os.WriteFile(filename, []byte(data), fileio.ExecutablePerms) 38 | if err != nil { 39 | log.AuditComponentFailed(usersComponentName) 40 | return nil, fmt.Errorf("writing %s to the combustion directory: %w", usersScriptName, err) 41 | } 42 | 43 | log.AuditComponentSuccessful(usersComponentName) 44 | return []string{usersScriptName}, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/container/image_digester.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/containers/image/v5/manifest" 8 | ) 9 | 10 | type imageInspector interface { 11 | Inspect(image string) (*manifest.Schema2List, error) 12 | } 13 | 14 | type ImageDigester struct { 15 | ImageInspector imageInspector 16 | } 17 | 18 | func (d *ImageDigester) ImageDigest(img string, arch string) (string, error) { 19 | schemas, err := d.ImageInspector.Inspect(img) 20 | if err != nil { 21 | return "", fmt.Errorf("inspecting image: %w", err) 22 | } 23 | 24 | for _, m := range schemas.Manifests { 25 | if m.Platform.OS == "linux" && m.Platform.Architecture == arch { 26 | digest := m.Digest.String() 27 | // This is done to remove "sha256:" from the digest 28 | if parts := strings.Split(digest, "sha256:"); len(parts) == 2 { 29 | digest = parts[1] 30 | } 31 | return digest, nil 32 | } 33 | } 34 | 35 | return "", fmt.Errorf("image is not built for linux/%s", arch) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/container/image_digester_test.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/containers/image/v5/manifest" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type mockImageInspector struct { 13 | inspect func(image string) (*manifest.Schema2List, error) 14 | } 15 | 16 | func (m mockImageInspector) Inspect(img string) (*manifest.Schema2List, error) { 17 | if m.inspect != nil { 18 | return m.inspect(img) 19 | } 20 | 21 | panic("not implemented") 22 | } 23 | 24 | func TestImageDigestValid(t *testing.T) { 25 | helloWorldManifest := &manifest.Schema2List{ 26 | SchemaVersion: 2, 27 | MediaType: "application/vnd.docker.distribution.manifest.list.v2+json", 28 | Manifests: []manifest.Schema2ManifestDescriptor{ 29 | { 30 | Schema2Descriptor: manifest.Schema2Descriptor{ 31 | MediaType: "application/vnd.docker.distribution.manifest.v2+json", 32 | Size: 1156, 33 | Digest: "sha256:3dfc05677ed97fdf620a3af556d6fe44ec3747262cbf1b4c0c20eed284fd7290", 34 | }, 35 | Platform: manifest.Schema2PlatformSpec{ 36 | Architecture: "amd64", 37 | OS: "linux", 38 | }, 39 | }, 40 | { 41 | Schema2Descriptor: manifest.Schema2Descriptor{ 42 | MediaType: "application/vnd.docker.distribution.manifest.v2+json", 43 | Size: 1156, 44 | Digest: "sha256:7c831ce05c671702726fc2951fe85048b0b9559f4105b80363424aa935bff2d1", 45 | }, 46 | Platform: manifest.Schema2PlatformSpec{ 47 | Architecture: "arm64", 48 | OS: "linux", 49 | }, 50 | }, 51 | }, 52 | } 53 | 54 | expectedDigest := "3dfc05677ed97fdf620a3af556d6fe44ec3747262cbf1b4c0c20eed284fd7290" 55 | 56 | d := ImageDigester{ 57 | ImageInspector: mockImageInspector{ 58 | inspect: func(image string) (*manifest.Schema2List, error) { 59 | return helloWorldManifest, nil 60 | }, 61 | }, 62 | } 63 | 64 | digest, err := d.ImageDigest("hello-world:latest", "amd64") 65 | require.NoError(t, err) 66 | assert.Equal(t, expectedDigest, digest) 67 | } 68 | 69 | func TestImageDigestNoSchemaFound(t *testing.T) { 70 | d := ImageDigester{ 71 | ImageInspector: mockImageInspector{ 72 | inspect: func(image string) (*manifest.Schema2List, error) { 73 | return &manifest.Schema2List{}, nil 74 | }, 75 | }, 76 | } 77 | 78 | digest, err := d.ImageDigest("hello-world:latest", "amd64") 79 | require.EqualError(t, err, "image is not built for linux/amd64") 80 | assert.Empty(t, digest) 81 | } 82 | 83 | func TestImageDigestError(t *testing.T) { 84 | d := ImageDigester{ 85 | ImageInspector: mockImageInspector{ 86 | inspect: func(image string) (*manifest.Schema2List, error) { 87 | return nil, fmt.Errorf("image not found") 88 | }, 89 | }, 90 | } 91 | 92 | digest, err := d.ImageDigest("hello-world:latest", "amd64") 93 | require.EqualError(t, err, "inspecting image: image not found") 94 | assert.Empty(t, digest) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/eib/eib_test.go: -------------------------------------------------------------------------------- 1 | package eib 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSetupBuildDirectory_EmptyRootDir(t *testing.T) { 13 | buildDir, err := SetupBuildDirectory("") 14 | require.NoError(t, err) 15 | 16 | defer func() { 17 | assert.NoError(t, os.RemoveAll(buildDir)) 18 | }() 19 | 20 | require.DirExists(t, buildDir) 21 | assert.Contains(t, buildDir, "build-") 22 | } 23 | 24 | func TestSetupBuildDir_NonEmptyRootDir(t *testing.T) { 25 | tests := []struct { 26 | name string 27 | rootDir string 28 | }{ 29 | { 30 | name: "Existing root dir", 31 | rootDir: func() string { 32 | tmpDir, err := os.MkdirTemp("", "eib-test-") 33 | require.NoError(t, err) 34 | 35 | return tmpDir 36 | }(), 37 | }, 38 | { 39 | name: "Non-existing root dir", 40 | rootDir: "some-non-existing-dir", 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | t.Run(test.name, func(t *testing.T) { 46 | defer func() { 47 | assert.NoError(t, os.RemoveAll(test.rootDir)) 48 | }() 49 | 50 | buildDir, err := SetupBuildDirectory(test.rootDir) 51 | require.NoError(t, err) 52 | 53 | require.DirExists(t, buildDir) 54 | assert.Contains(t, buildDir, filepath.Join(test.rootDir, "build-")) 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/fileio/testdata/copy-files/gpg.gpg: -------------------------------------------------------------------------------- 1 | copy-files-test-data -------------------------------------------------------------------------------- /pkg/fileio/testdata/copy-files/rpm.rpm: -------------------------------------------------------------------------------- 1 | copy-files-test-data -------------------------------------------------------------------------------- /pkg/fileio/testdata/copy-files/sub1-copy-files/dummy.txt: -------------------------------------------------------------------------------- 1 | copy-files-test-data -------------------------------------------------------------------------------- /pkg/fileio/testdata/copy-files/sub1-copy-files/gpg.gpg: -------------------------------------------------------------------------------- 1 | copy-files-test-data -------------------------------------------------------------------------------- /pkg/fileio/testdata/copy-files/sub1-copy-files/rpm.rpm: -------------------------------------------------------------------------------- 1 | copy-files-test-data -------------------------------------------------------------------------------- /pkg/fileio/testdata/copy-files/unwritable.txt: -------------------------------------------------------------------------------- 1 | copy-files-test-data -------------------------------------------------------------------------------- /pkg/http/download.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/schollz/progressbar/v3" 12 | "github.com/suse-edge/edge-image-builder/pkg/log" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // DownloadFile downloads a file from the specified URL and stores it to the given path. 17 | // 18 | // Optionally provide an additional cache writer in cases where the pending download 19 | // must be stored to other locations alongside the given path. 20 | func DownloadFile(ctx context.Context, url, path string, cache io.Writer) error { 21 | filename := filepath.Base(path) 22 | 23 | zap.S().Infof("Downloading file '%s' from '%s' to '%s'...", filename, url, filepath.Dir(path)) 24 | 25 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) 26 | if err != nil { 27 | return fmt.Errorf("creating request: %w", err) 28 | } 29 | 30 | resp, err := http.DefaultClient.Do(req) 31 | if err != nil { 32 | return fmt.Errorf("executing request: %w", err) 33 | } 34 | defer resp.Body.Close() 35 | 36 | if resp.StatusCode != http.StatusOK { 37 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 38 | } 39 | 40 | file, err := os.Create(path) 41 | if err != nil { 42 | return fmt.Errorf("creating file: %w", err) 43 | } 44 | defer file.Close() 45 | 46 | var writers []io.Writer 47 | writers = append(writers, file) 48 | 49 | if cache != nil { 50 | writers = append(writers, cache) 51 | } 52 | 53 | message := fmt.Sprintf("Downloading file: %s", filename) 54 | 55 | if resp.ContentLength == -1 { 56 | // Only audit the message since progress bars of unknown length 57 | // (i.e. spinners) are not properly rendered. 58 | log.Audit(message) 59 | } else { 60 | bar := progressbar.DefaultBytes(resp.ContentLength, message) 61 | writers = append(writers, bar) 62 | } 63 | 64 | if _, err = io.Copy(io.MultiWriter(writers...), resp.Body); err != nil { 65 | return fmt.Errorf("storing response: %w", err) 66 | } 67 | 68 | zap.S().Infof("Downloading file '%s' completed", filename) 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /pkg/http/download_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package http 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestDownloadFile_Successful_NoCache(t *testing.T) { 16 | url := "https://raw.githubusercontent.com/suse-edge/edge-image-builder/main/README.md" 17 | path := "README.md" 18 | 19 | require.NoError(t, DownloadFile(context.Background(), url, path, nil)) 20 | defer func() { 21 | assert.NoError(t, os.Remove(path)) 22 | }() 23 | 24 | assert.FileExists(t, path) 25 | } 26 | 27 | func TestDownloadFile_Successful_Cache(t *testing.T) { 28 | url := "https://raw.githubusercontent.com/suse-edge/edge-image-builder/main/README.md" 29 | path := "README.md" 30 | 31 | var sb strings.Builder 32 | 33 | require.NoError(t, DownloadFile(context.Background(), url, path, &sb)) 34 | defer func() { 35 | assert.NoError(t, os.Remove(path)) 36 | }() 37 | 38 | require.FileExists(t, path) 39 | 40 | b, err := os.ReadFile(path) 41 | require.NoError(t, err) 42 | 43 | assert.Equal(t, sb.String(), string(b)) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/http/download_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDownloadFile(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | ctx context.Context 15 | url string 16 | path string 17 | expectedErr string 18 | }{ 19 | { 20 | name: "Nil context", 21 | expectedErr: "creating request: net/http: nil Context", 22 | }, 23 | { 24 | name: "Invalid URL", 25 | ctx: context.Background(), 26 | url: "invalid-url", 27 | expectedErr: "executing request: Get \"invalid-url\": unsupported protocol scheme \"\"", 28 | }, 29 | { 30 | name: "Unexpected status", 31 | ctx: context.Background(), 32 | url: "https://github.com/suse-edge/eib", 33 | expectedErr: "unexpected status code: 404", 34 | }, 35 | { 36 | name: "Error creating file", 37 | ctx: context.Background(), 38 | url: "https://github.com/suse-edge/edge-image-builder", 39 | path: "downloads/abc", 40 | expectedErr: "creating file: open downloads/abc: no such file or directory", 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | t.Run(test.name, func(t *testing.T) { 46 | err := DownloadFile(test.ctx, test.url, test.path, nil) 47 | require.Error(t, err) 48 | assert.EqualError(t, err, test.expectedErr) 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/image/context.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | type LocalRPMConfig struct { 4 | // RPMPath is the path to the directory holding RPMs that will be side-loaded 5 | RPMPath string 6 | // GPGKeysPath specifies the path to the directory that holds the GPG keys that the side-loaded RPMs have been signed with 7 | GPGKeysPath string 8 | } 9 | 10 | type Context struct { 11 | // ImageConfigDir is the root directory storing all configuration files. 12 | ImageConfigDir string 13 | // BuildDir is the directory used for assembling the different components used in a build. 14 | BuildDir string 15 | // CombustionDir is a subdirectory under BuildDir containing the Combustion script and its smaller related files. 16 | CombustionDir string 17 | // ArtefactsDir is a subdirectory under BuildDir containing the larger Combustion related files. 18 | ArtefactsDir string 19 | // ImageDefinition contains the image definition properties. 20 | ImageDefinition *Definition 21 | // ArtifactSources contains the information necessary for the deployment of external artifacts. 22 | ArtifactSources *ArtifactSources 23 | // CacheDir contains all of the artifacts that are cached for the build process. 24 | CacheDir string 25 | } 26 | 27 | type ArtifactSources struct { 28 | MetalLB struct { 29 | Chart string `yaml:"chart"` 30 | Repository string `yaml:"repository"` 31 | Version string `yaml:"version"` 32 | } `yaml:"metallb"` 33 | EndpointCopierOperator struct { 34 | Chart string `yaml:"chart"` 35 | Repository string `yaml:"repository"` 36 | Version string `yaml:"version"` 37 | } `yaml:"endpoint-copier-operator"` 38 | Kubernetes struct { 39 | K3s struct { 40 | SELinuxPackage string `yaml:"selinuxPackage"` 41 | SELinuxRepository string `yaml:"selinuxRepository"` 42 | } `yaml:"k3s"` 43 | Rke2 struct { 44 | SELinuxPackage string `yaml:"selinuxPackage"` 45 | SELinuxRepository string `yaml:"selinuxRepository"` 46 | } `yaml:"rke2"` 47 | } `yaml:"kubernetes"` 48 | } 49 | -------------------------------------------------------------------------------- /pkg/image/validation/elemental.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/suse-edge/edge-image-builder/pkg/combustion" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | const ( 15 | elementalComponent = "Elemental" 16 | elementalConfigFilename = "elemental_config.yaml" 17 | ) 18 | 19 | func validateElemental(ctx *image.Context) []FailedValidation { 20 | var failures []FailedValidation 21 | 22 | elementalConfigDir := filepath.Join(ctx.ImageConfigDir, "elemental") 23 | if _, err := os.Stat(elementalConfigDir); err != nil { 24 | if os.IsNotExist(err) { 25 | return nil 26 | } 27 | 28 | failures = append(failures, FailedValidation{ 29 | UserMessage: "Elemental config directory could not be read", 30 | Error: err, 31 | }) 32 | return failures 33 | } 34 | 35 | failures = append(failures, validateElementalConfiguration(ctx)...) 36 | failures = append(failures, validateElementalDir(elementalConfigDir)...) 37 | 38 | return failures 39 | } 40 | 41 | func validateElementalDir(elementalConfigDir string) []FailedValidation { 42 | var failures []FailedValidation 43 | 44 | elementalConfigDirEntries, err := os.ReadDir(elementalConfigDir) 45 | if err != nil { 46 | failures = append(failures, FailedValidation{ 47 | UserMessage: "Elemental config directory could not be read", 48 | Error: err, 49 | }) 50 | 51 | return failures 52 | } 53 | 54 | switch len(elementalConfigDirEntries) { 55 | case 0: 56 | failures = append(failures, FailedValidation{ 57 | UserMessage: "Elemental config directory should not be present if it is empty", 58 | }) 59 | case 1: 60 | if elementalConfigDirEntries[0].Name() != elementalConfigFilename { 61 | failures = append(failures, FailedValidation{ 62 | UserMessage: fmt.Sprintf("Elemental config file should only be named `%s`", elementalConfigFilename), 63 | }) 64 | } 65 | default: 66 | failures = append(failures, FailedValidation{ 67 | UserMessage: fmt.Sprintf("Elemental config directory should only contain a singular '%s' file", elementalConfigFilename), 68 | }) 69 | } 70 | 71 | return failures 72 | } 73 | 74 | func validateElementalConfiguration(ctx *image.Context) []FailedValidation { 75 | var failures []FailedValidation 76 | 77 | rpmDirEntries, err := os.ReadDir(combustion.RPMsPath(ctx)) 78 | if err != nil && !os.IsNotExist(err) { 79 | failures = append(failures, FailedValidation{ 80 | UserMessage: "RPM directory could not be read", 81 | Error: err, 82 | }) 83 | } 84 | 85 | var foundPackages []string 86 | var notFoundPackages []string 87 | for _, pkg := range combustion.ElementalPackages { 88 | if slices.ContainsFunc(rpmDirEntries, func(entry os.DirEntry) bool { 89 | return strings.Contains(entry.Name(), pkg) 90 | }) { 91 | foundPackages = append(foundPackages, pkg) 92 | } else { 93 | notFoundPackages = append(notFoundPackages, pkg) 94 | } 95 | } 96 | 97 | if len(foundPackages) == 0 { 98 | if ctx.ImageDefinition.OperatingSystem.Packages.RegCode == "" { 99 | failures = append(failures, FailedValidation{ 100 | UserMessage: fmt.Sprintf("Operating system package registration code field must be defined when using Elemental "+ 101 | "or the %s RPMs must be manually side-loaded", combustion.ElementalPackages), 102 | }) 103 | } 104 | } else if len(foundPackages) != len(combustion.ElementalPackages) { 105 | failures = append(failures, FailedValidation{ 106 | UserMessage: fmt.Sprintf("Not all of the necessary Elemental packages are provided, packages found: %s, packages missing: %s", foundPackages, notFoundPackages), 107 | }) 108 | } 109 | 110 | return failures 111 | } 112 | -------------------------------------------------------------------------------- /pkg/image/validation/image.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/suse-edge/edge-image-builder/pkg/log" 12 | 13 | "github.com/suse-edge/edge-image-builder/pkg/image" 14 | ) 15 | 16 | const ( 17 | imageComponent = "Image" 18 | ) 19 | 20 | func validateImage(ctx *image.Context) []FailedValidation { 21 | def := ctx.ImageDefinition 22 | 23 | validImageTypes := []string{image.TypeISO, image.TypeRAW} 24 | 25 | var failures []FailedValidation 26 | 27 | failures = append(failures, validateArch(def)...) 28 | 29 | if def.Image.ImageType == "" { 30 | failures = append(failures, FailedValidation{ 31 | UserMessage: "The 'imageType' field is required in the 'image' section.", 32 | }) 33 | } else if !slices.Contains(validImageTypes, def.Image.ImageType) { 34 | msg := fmt.Sprintf("The 'imageType' field must be one of: %s", strings.Join(validImageTypes, ", ")) 35 | failures = append(failures, FailedValidation{ 36 | UserMessage: msg, 37 | }) 38 | } 39 | 40 | if def.Image.OutputImageName == "" { 41 | failures = append(failures, FailedValidation{ 42 | UserMessage: "The 'outputImageName' field is required in the 'image' section.", 43 | }) 44 | } 45 | 46 | if def.Image.BaseImage == "" { 47 | failures = append(failures, FailedValidation{ 48 | UserMessage: "The 'baseImage' field is required in the 'image' section.", 49 | }) 50 | } else { 51 | baseImageFilename := filepath.Join(ctx.ImageConfigDir, "base-images", def.Image.BaseImage) 52 | _, err := os.Stat(baseImageFilename) 53 | if err != nil { 54 | if os.IsNotExist(err) { 55 | msg := fmt.Sprintf("The specified base image '%s' cannot be found.", def.Image.BaseImage) 56 | failures = append(failures, FailedValidation{ 57 | UserMessage: msg, 58 | }) 59 | } else { 60 | msg := fmt.Sprintf("The specified base image '%s' cannot be read. See the logs for more information.", def.Image.BaseImage) 61 | failures = append(failures, FailedValidation{ 62 | UserMessage: msg, 63 | Error: err, 64 | }) 65 | } 66 | } 67 | } 68 | 69 | return failures 70 | } 71 | 72 | func validateArch(def *image.Definition) []FailedValidation { 73 | var failures []FailedValidation 74 | 75 | validArchTypes := []string{string(image.ArchTypeARM), string(image.ArchTypeX86)} 76 | if def.Image.Arch == "" { 77 | failures = append(failures, FailedValidation{ 78 | UserMessage: "The 'arch' field is required in the 'image' section.", 79 | }) 80 | 81 | return failures 82 | } else if !slices.Contains(validArchTypes, string(def.Image.Arch)) { 83 | msg := fmt.Sprintf("The 'arch' field must be one of: %s", strings.Join(validArchTypes, ", ")) 84 | failures = append(failures, FailedValidation{ 85 | UserMessage: msg, 86 | }) 87 | 88 | return failures 89 | } 90 | 91 | if runtime.GOARCH != def.Image.Arch.Short() { 92 | log.Auditf("Image build may fail as host architecture does not match the defined architecture of the "+ 93 | "output image.\nDetected: %s, Defined: %s", runtime.GOARCH, def.Image.Arch.Short()) 94 | } 95 | 96 | return failures 97 | } 98 | -------------------------------------------------------------------------------- /pkg/image/validation/image_test.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | ) 12 | 13 | func TestValidateImage(t *testing.T) { 14 | imageConfigDir, err := os.MkdirTemp("", "eib-image-tests-") 15 | require.NoError(t, err) 16 | defer func() { 17 | _ = os.RemoveAll(imageConfigDir) 18 | }() 19 | 20 | testImagesDir := filepath.Join(imageConfigDir, "base-images") 21 | err = os.Mkdir(testImagesDir, os.ModePerm) 22 | require.NoError(t, err) 23 | 24 | testBaseImageFilename := filepath.Join(testImagesDir, "base-image.iso") 25 | _, err = os.Create(testBaseImageFilename) 26 | require.NoError(t, err) 27 | 28 | tests := map[string]struct { 29 | ImageDefinition image.Definition 30 | ExpectedFailedMessages []string 31 | }{ 32 | `complete valid definition`: { 33 | ImageDefinition: image.Definition{ 34 | Image: image.Image{ 35 | ImageType: image.TypeISO, 36 | Arch: image.ArchTypeX86, 37 | BaseImage: "base-image.iso", 38 | OutputImageName: "eib-created.iso", 39 | }, 40 | }, 41 | }, 42 | `missing all fields`: { 43 | ImageDefinition: image.Definition{ 44 | Image: image.Image{}, 45 | }, 46 | ExpectedFailedMessages: []string{ 47 | "The 'imageType' field is required in the 'image' section.", 48 | "The 'arch' field is required in the 'image' section.", 49 | "The 'outputImageName' field is required in the 'image' section.", 50 | "The 'baseImage' field is required in the 'image' section.", 51 | }, 52 | }, 53 | `invalid enum values`: { 54 | ImageDefinition: image.Definition{ 55 | Image: image.Image{ 56 | ImageType: "foo", 57 | Arch: "bar", 58 | BaseImage: "base-image.iso", 59 | OutputImageName: "eib-created.iso", 60 | }, 61 | }, 62 | ExpectedFailedMessages: []string{ 63 | "The 'imageType' field must be one of: iso, raw", 64 | "The 'arch' field must be one of: aarch64, x86_64", 65 | }, 66 | }, 67 | `base image not found`: { 68 | ImageDefinition: image.Definition{ 69 | Image: image.Image{ 70 | ImageType: image.TypeISO, 71 | Arch: image.ArchTypeX86, 72 | BaseImage: "not-there", 73 | OutputImageName: "eib-created.iso", 74 | }, 75 | }, 76 | ExpectedFailedMessages: []string{ 77 | "The specified base image 'not-there' cannot be found.", 78 | }, 79 | }, 80 | } 81 | 82 | for name, test := range tests { 83 | t.Run(name, func(t *testing.T) { 84 | imageDef := test.ImageDefinition 85 | ctx := image.Context{ 86 | ImageConfigDir: imageConfigDir, 87 | ImageDefinition: &imageDef, 88 | } 89 | failedValidations := validateImage(&ctx) 90 | assert.Len(t, failedValidations, len(test.ExpectedFailedMessages)) 91 | 92 | var foundMessages []string 93 | for _, foundValidation := range failedValidations { 94 | foundMessages = append(foundMessages, foundValidation.UserMessage) 95 | } 96 | 97 | for _, expectedMessage := range test.ExpectedFailedMessages { 98 | assert.Contains(t, foundMessages, expectedMessage) 99 | } 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/image/validation/registry.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/containers/image/v5/docker/reference" 7 | "github.com/suse-edge/edge-image-builder/pkg/image" 8 | ) 9 | 10 | const ( 11 | registryComponent = "Artifact Registry" 12 | ) 13 | 14 | func validateEmbeddedArtifactRegistry(ctx *image.Context) []FailedValidation { 15 | var failures []FailedValidation 16 | 17 | failures = append(failures, validateRegistries(&ctx.ImageDefinition.EmbeddedArtifactRegistry)...) 18 | failures = append(failures, validateContainerImages(&ctx.ImageDefinition.EmbeddedArtifactRegistry)...) 19 | 20 | return failures 21 | } 22 | 23 | func validateContainerImages(ear *image.EmbeddedArtifactRegistry) []FailedValidation { 24 | var failures []FailedValidation 25 | 26 | seenContainerImages := make(map[string]bool) 27 | for _, cImage := range ear.ContainerImages { 28 | if cImage.Name == "" { 29 | failures = append(failures, FailedValidation{ 30 | UserMessage: "The 'name' field is required for each entry in 'images'.", 31 | }) 32 | } 33 | 34 | if seenContainerImages[cImage.Name] { 35 | msg := fmt.Sprintf("Duplicate image name '%s' found in the 'images' section.", cImage.Name) 36 | failures = append(failures, FailedValidation{ 37 | UserMessage: msg, 38 | }) 39 | } 40 | seenContainerImages[cImage.Name] = true 41 | } 42 | 43 | return failures 44 | } 45 | 46 | func validateRegistries(ear *image.EmbeddedArtifactRegistry) []FailedValidation { 47 | var failures []FailedValidation 48 | 49 | failures = append(failures, validateURLs(ear)...) 50 | failures = append(failures, validateCredentials(ear)...) 51 | 52 | return failures 53 | } 54 | 55 | func validateURLs(ear *image.EmbeddedArtifactRegistry) []FailedValidation { 56 | var failures []FailedValidation 57 | 58 | seenRegistryURLs := make(map[string]bool) 59 | for _, registry := range ear.Registries { 60 | if registry.URI == "" { 61 | failures = append(failures, FailedValidation{ 62 | UserMessage: "The 'uri' field is required for each entry in 'embeddedArtifactRegistry.registries'.", 63 | }) 64 | } 65 | 66 | _, err := reference.Parse(registry.URI) 67 | if err != nil { 68 | failures = append(failures, FailedValidation{ 69 | UserMessage: fmt.Sprintf("Embedded artifact registry URI '%s' could not be parsed.", registry.URI), 70 | Error: err, 71 | }) 72 | 73 | continue 74 | } 75 | 76 | if seenRegistryURLs[registry.URI] { 77 | msg := fmt.Sprintf("Duplicate registry URI '%s' found in the 'embeddedArtifactRegistry.registries' section.", registry.URI) 78 | failures = append(failures, FailedValidation{ 79 | UserMessage: msg, 80 | }) 81 | } 82 | 83 | seenRegistryURLs[registry.URI] = true 84 | } 85 | 86 | return failures 87 | } 88 | 89 | func validateCredentials(ear *image.EmbeddedArtifactRegistry) []FailedValidation { 90 | var failures []FailedValidation 91 | 92 | for _, registry := range ear.Registries { 93 | if registry.Authentication.Username == "" { 94 | failures = append(failures, FailedValidation{ 95 | UserMessage: "The 'username' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.", 96 | }) 97 | } 98 | 99 | if registry.Authentication.Password == "" { 100 | failures = append(failures, FailedValidation{ 101 | UserMessage: "The 'password' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.", 102 | }) 103 | } 104 | } 105 | 106 | return failures 107 | } 108 | -------------------------------------------------------------------------------- /pkg/image/validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "github.com/suse-edge/edge-image-builder/pkg/image" 5 | ) 6 | 7 | type FailedValidation struct { 8 | UserMessage string 9 | Error error 10 | } 11 | 12 | type validateComponent func(ctx *image.Context) []FailedValidation 13 | 14 | func ValidateDefinition(ctx *image.Context) map[string][]FailedValidation { 15 | failures := map[string][]FailedValidation{} 16 | 17 | validations := map[string]validateComponent{ 18 | versionComponent: validateVersion, 19 | imageComponent: validateImage, 20 | osComponent: validateOperatingSystem, 21 | registryComponent: validateEmbeddedArtifactRegistry, 22 | k8sComponent: validateKubernetes, 23 | elementalComponent: validateElemental, 24 | } 25 | for componentName, v := range validations { 26 | componentFailures := v(ctx) 27 | 28 | if len(componentFailures) > 0 { 29 | failures[componentName] = componentFailures 30 | } 31 | } 32 | 33 | return failures 34 | } 35 | 36 | func findDuplicates(items []string) []string { 37 | var duplicates []string 38 | 39 | seen := make(map[string]bool) 40 | for _, item := range items { 41 | if seen[item] { 42 | duplicates = append(duplicates, item) 43 | } 44 | seen[item] = true 45 | } 46 | 47 | return duplicates 48 | } 49 | -------------------------------------------------------------------------------- /pkg/image/validation/version.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "github.com/suse-edge/edge-image-builder/pkg/image" 5 | ) 6 | 7 | const ( 8 | versionComponent = "Version" 9 | ) 10 | 11 | // Note: This method of validating the EIB version in the image definition is only a temporary implementation 12 | // until a more robust solution can be found. 13 | func validateVersion(ctx *image.Context) []FailedValidation { 14 | var failures []FailedValidation 15 | definition := *ctx.ImageDefinition 16 | 17 | var apiVersionsDefined bool 18 | for i := range definition.Kubernetes.Helm.Charts { 19 | if len(definition.Kubernetes.Helm.Charts[i].APIVersions) != 0 { 20 | apiVersionsDefined = true 21 | } 22 | } 23 | 24 | if definition.APIVersion == "1.0" && apiVersionsDefined { 25 | failures = append(failures, FailedValidation{ 26 | UserMessage: "Helm chart APIVersions field is not supported in EIB version 1.0, must use EIB version 1.1", 27 | }) 28 | } 29 | 30 | if definition.APIVersion == "1.0" && definition.OperatingSystem.EnableFIPS { 31 | failures = append(failures, FailedValidation{ 32 | UserMessage: "Automated FIPS configuration is not supported in EIB version 1.0, please use EIB version >= 1.1", 33 | }) 34 | } 35 | 36 | if definition.APIVersion != "1.2" && definition.Kubernetes.Network.APIVIP6 != "" { 37 | failures = append(failures, FailedValidation{ 38 | UserMessage: "IPv6 support for the Kubernetes API VIP is only available in EIB version >= 1.2", 39 | }) 40 | } 41 | 42 | return failures 43 | } 44 | -------------------------------------------------------------------------------- /pkg/image/validation/version_test.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/suse-edge/edge-image-builder/pkg/image" 8 | ) 9 | 10 | func TestValidateVersion(t *testing.T) { 11 | tests := map[string]struct { 12 | ImageDefinition image.Definition 13 | ExpectedFailedMessages []string 14 | }{ 15 | `valid version with Helm APIVersions`: { 16 | ImageDefinition: image.Definition{ 17 | APIVersion: "1.1", 18 | Kubernetes: image.Kubernetes{Helm: image.Helm{Charts: []image.HelmChart{ 19 | { 20 | APIVersions: []string{"1.30.3+k3s1"}, 21 | }, 22 | }}}, 23 | }, 24 | }, 25 | `invalid version with Helm APIVersions`: { 26 | ImageDefinition: image.Definition{ 27 | APIVersion: "1.0", 28 | Kubernetes: image.Kubernetes{Helm: image.Helm{Charts: []image.HelmChart{ 29 | { 30 | APIVersions: []string{"1.30.3+k3s1"}, 31 | }, 32 | }}}, 33 | }, 34 | ExpectedFailedMessages: []string{ 35 | "Helm chart APIVersions field is not supported in EIB version 1.0, must use EIB version 1.1", 36 | }, 37 | }, 38 | `invalid version with FIPS enabled`: { 39 | ImageDefinition: image.Definition{ 40 | APIVersion: "1.0", 41 | OperatingSystem: image.OperatingSystem{ 42 | EnableFIPS: true, 43 | }, 44 | }, 45 | ExpectedFailedMessages: []string{ 46 | "Automated FIPS configuration is not supported in EIB version 1.0, please use EIB version >= 1.1", 47 | }, 48 | }, 49 | } 50 | 51 | for name, test := range tests { 52 | t.Run(name, func(t *testing.T) { 53 | imageDef := test.ImageDefinition 54 | ctx := image.Context{ 55 | ImageDefinition: &imageDef, 56 | } 57 | failedValidations := validateVersion(&ctx) 58 | assert.Len(t, failedValidations, len(test.ExpectedFailedMessages)) 59 | 60 | var foundMessages []string 61 | for _, foundValidation := range failedValidations { 62 | foundMessages = append(foundMessages, foundValidation.UserMessage) 63 | } 64 | 65 | for _, expectedMessage := range test.ExpectedFailedMessages { 66 | assert.Contains(t, foundMessages, expectedMessage) 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pkg/kubernetes/cni.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func (c *Cluster) ExtractCNI() (cni string, multusEnabled bool, err error) { 9 | switch configuredCNI := c.ServerConfig[cniKey].(type) { 10 | case string: 11 | if configuredCNI == "" { 12 | return "", false, fmt.Errorf("cni not configured") 13 | } 14 | 15 | var cnis []string 16 | for _, cni = range strings.Split(configuredCNI, ",") { 17 | cnis = append(cnis, strings.TrimSpace(cni)) 18 | } 19 | 20 | return parseCNIs(cnis) 21 | 22 | case []string: 23 | return parseCNIs(configuredCNI) 24 | 25 | case []any: 26 | var cnis []string 27 | for _, cni := range configuredCNI { 28 | c, ok := cni.(string) 29 | if !ok { 30 | return "", false, fmt.Errorf("invalid cni value: %v", cni) 31 | } 32 | cnis = append(cnis, c) 33 | } 34 | 35 | return parseCNIs(cnis) 36 | 37 | default: 38 | return "", false, fmt.Errorf("invalid cni: %v", configuredCNI) 39 | } 40 | } 41 | 42 | func parseCNIs(cnis []string) (cni string, multusEnabled bool, err error) { 43 | const multusPlugin = "multus" 44 | 45 | switch len(cnis) { 46 | case 1: 47 | cni = cnis[0] 48 | if cni == multusPlugin { 49 | return "", false, fmt.Errorf("multus must be used alongside another primary cni selection") 50 | } 51 | case 2: 52 | if cnis[0] == multusPlugin { 53 | cni = cnis[1] 54 | multusEnabled = true 55 | } else { 56 | return "", false, fmt.Errorf("multiple cni values are only allowed if multus is the first one") 57 | } 58 | default: 59 | return "", false, fmt.Errorf("invalid cni value: %v", cnis) 60 | } 61 | 62 | return cni, multusEnabled, nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/kubernetes/cni_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func TestExtractCNI(t *testing.T) { 12 | tests := map[string]struct { 13 | input map[string]any 14 | expectedCNI string 15 | expectedMultusEnabled bool 16 | expectedErr string 17 | }{ 18 | "CNI not configured": { 19 | input: map[string]any{}, 20 | expectedErr: "invalid cni: ", 21 | }, 22 | "Empty CNI string": { 23 | input: map[string]any{ 24 | "cni": "", 25 | }, 26 | expectedErr: "cni not configured", 27 | }, 28 | "Empty CNI list": { 29 | input: map[string]any{ 30 | "cni": []string{}, 31 | }, 32 | expectedErr: "invalid cni value: []", 33 | }, 34 | "Multiple CNI list": { 35 | input: map[string]any{ 36 | "cni": []string{"canal", "calico", "cilium"}, 37 | }, 38 | expectedErr: "invalid cni value: [canal calico cilium]", 39 | }, 40 | "Valid CNI string": { 41 | input: map[string]any{ 42 | "cni": "calico", 43 | }, 44 | expectedCNI: "calico", 45 | }, 46 | "Valid CNI list": { 47 | input: map[string]any{ 48 | "cni": []string{"calico"}, 49 | }, 50 | expectedCNI: "calico", 51 | }, 52 | "Valid CNI string with multus": { 53 | input: map[string]any{ 54 | "cni": "multus, calico", 55 | }, 56 | expectedCNI: "calico", 57 | expectedMultusEnabled: true, 58 | }, 59 | "Valid CNI list with multus": { 60 | input: map[string]any{ 61 | "cni": []string{"multus", "calico"}, 62 | }, 63 | expectedCNI: "calico", 64 | expectedMultusEnabled: true, 65 | }, 66 | "Invalid standalone multus": { 67 | input: map[string]any{ 68 | "cni": "multus", 69 | }, 70 | expectedErr: "multus must be used alongside another primary cni selection", 71 | }, 72 | "Invalid standalone multus list": { 73 | input: map[string]any{ 74 | "cni": []string{"multus"}, 75 | }, 76 | expectedErr: "multus must be used alongside another primary cni selection", 77 | }, 78 | "Valid CNI with invalid multus placement": { 79 | input: map[string]any{ 80 | "cni": "cilium, multus", 81 | }, 82 | expectedErr: "multiple cni values are only allowed if multus is the first one", 83 | }, 84 | "Valid CNI list with invalid multus placement": { 85 | input: map[string]any{ 86 | "cni": []string{"cilium", "multus"}, 87 | }, 88 | expectedErr: "multiple cni values are only allowed if multus is the first one", 89 | }, 90 | "Invalid CNI list": { 91 | input: map[string]any{ 92 | "cni": []any{"cilium", 6}, 93 | }, 94 | expectedErr: "invalid cni value: 6", 95 | }, 96 | "Invalid CNI format": { 97 | input: map[string]any{ 98 | "cni": 6, 99 | }, 100 | expectedErr: "invalid cni: 6", 101 | }, 102 | } 103 | 104 | for name, test := range tests { 105 | t.Run(name, func(t *testing.T) { 106 | b, err := yaml.Marshal(test.input) 107 | require.NoError(t, err) 108 | 109 | var config map[string]any 110 | require.NoError(t, yaml.Unmarshal(b, &config)) 111 | 112 | cluster := Cluster{ 113 | ServerConfig: config, 114 | } 115 | 116 | cni, multusEnabled, err := cluster.ExtractCNI() 117 | 118 | if test.expectedErr != "" { 119 | require.Error(t, err) 120 | assert.EqualError(t, err, test.expectedErr) 121 | assert.False(t, multusEnabled) 122 | assert.Empty(t, cni) 123 | } else { 124 | require.NoError(t, err) 125 | assert.Equal(t, test.expectedCNI, cni) 126 | assert.Equal(t, test.expectedMultusEnabled, multusEnabled) 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/kubernetes/install_script_downloader.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 10 | "github.com/suse-edge/edge-image-builder/pkg/http" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | const ( 15 | rke2InstallScriptURL = "https://get.rke2.io" 16 | k3sInstallScriptURL = "https://get.k3s.io" 17 | ) 18 | 19 | type ScriptDownloader struct{} 20 | 21 | func (d ScriptDownloader) DownloadInstallScript(distribution, destinationPath string) (string, error) { 22 | var scriptURL string 23 | 24 | switch distribution { 25 | case image.KubernetesDistroRKE2: 26 | scriptURL = rke2InstallScriptURL 27 | case image.KubernetesDistroK3S: 28 | scriptURL = k3sInstallScriptURL 29 | default: 30 | return "", fmt.Errorf("unsupported distribution: %s", distribution) 31 | } 32 | 33 | installer := fmt.Sprintf("%s_installer.sh", distribution) 34 | destinationPath = filepath.Join(destinationPath, installer) 35 | 36 | if err := http.DownloadFile(context.Background(), scriptURL, destinationPath, nil); err != nil { 37 | return "", fmt.Errorf("downloading script: %w", err) 38 | } 39 | 40 | if err := os.Chmod(destinationPath, fileio.ExecutablePerms); err != nil { 41 | return "", fmt.Errorf("modifying script permissions: %w", err) 42 | } 43 | 44 | return installer, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/kubernetes/selinux.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/http" 10 | "github.com/suse-edge/edge-image-builder/pkg/image" 11 | ) 12 | 13 | func SELinuxPackage(version string, sources *image.ArtifactSources) (string, error) { 14 | 15 | switch { 16 | case strings.Contains(version, image.KubernetesDistroK3S): 17 | return sources.Kubernetes.K3s.SELinuxPackage, nil 18 | case strings.Contains(version, image.KubernetesDistroRKE2): 19 | return sources.Kubernetes.Rke2.SELinuxPackage, nil 20 | default: 21 | return "", fmt.Errorf("invalid kubernetes version: %s", version) 22 | } 23 | } 24 | 25 | func SELinuxRepository(version string, sources *image.ArtifactSources) (image.AddRepo, error) { 26 | var url string 27 | 28 | switch { 29 | case strings.Contains(version, image.KubernetesDistroK3S): 30 | url = sources.Kubernetes.K3s.SELinuxRepository 31 | case strings.Contains(version, image.KubernetesDistroRKE2): 32 | url = sources.Kubernetes.Rke2.SELinuxRepository 33 | default: 34 | return image.AddRepo{}, fmt.Errorf("invalid kubernetes version: %s", version) 35 | } 36 | 37 | return image.AddRepo{ 38 | URL: url, 39 | Unsigned: true, 40 | }, nil 41 | } 42 | 43 | func DownloadSELinuxRPMsSigningKey(gpgKeysDir string) error { 44 | const rancherSigningKeyURL = "https://rpm.rancher.io/public.key" 45 | var signingKeyPath = filepath.Join(gpgKeysDir, "rancher-public.key") 46 | 47 | return http.DownloadFile(context.Background(), rancherSigningKeyURL, signingKeyPath, nil) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/kubernetes/testdata/default/agent.yaml: -------------------------------------------------------------------------------- 1 | cni: calico 2 | token: totally-not-generated-one 3 | debug: true 4 | -------------------------------------------------------------------------------- /pkg/kubernetes/testdata/default/server.yaml: -------------------------------------------------------------------------------- 1 | cni: calico 2 | token: totally-not-generated-one 3 | selinux: true 4 | -------------------------------------------------------------------------------- /pkg/kubernetes/testdata/dualstack-prio-ipv4/agent.yaml: -------------------------------------------------------------------------------- 1 | cni: calico 2 | token: totally-not-generated-one 3 | debug: true 4 | -------------------------------------------------------------------------------- /pkg/kubernetes/testdata/dualstack-prio-ipv4/server.yaml: -------------------------------------------------------------------------------- 1 | cni: calico 2 | token: totally-not-generated-one 3 | selinux: true 4 | cluster-cidr: 10.42.0.0/16,fd12:3456:789b::/48 5 | service-cidr: 10.43.0.0/16,fd12:3456:789c::/112 -------------------------------------------------------------------------------- /pkg/kubernetes/testdata/dualstack-prio-ipv6/agent.yaml: -------------------------------------------------------------------------------- 1 | cni: calico 2 | token: totally-not-generated-one 3 | debug: true 4 | -------------------------------------------------------------------------------- /pkg/kubernetes/testdata/dualstack-prio-ipv6/server.yaml: -------------------------------------------------------------------------------- 1 | cni: calico 2 | token: totally-not-generated-one 3 | selinux: true 4 | cluster-cidr: fd12:3456:789b::/48,10.42.0.0/16 5 | service-cidr: fd12:3456:789c::/112,10.43.0.0/16 -------------------------------------------------------------------------------- /pkg/log/audit.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "go.uber.org/zap" 8 | "golang.org/x/text/cases" 9 | "golang.org/x/text/language" 10 | ) 11 | 12 | const ( 13 | lineLength = 40 14 | 15 | messageSuccess = "SUCCESS" 16 | messageSkipped = "SKIPPED" 17 | messageFailed = "FAILED " // leave the trailing space for consistent lengths 18 | ) 19 | 20 | // Audit displays a message to the user. This shouldn't be used for debug logging purposes; all 21 | // messages passed in here should be user-readable. 22 | func Audit(message string) { 23 | doAudit(message, nil) 24 | } 25 | 26 | func Auditf(message string, args ...any) { 27 | auditMe := fmt.Sprintf(message, args...) 28 | doAudit(auditMe, nil) 29 | } 30 | 31 | func AuditInfo(message string) { 32 | doAudit(message, zap.S().Info) 33 | } 34 | 35 | func AuditInfof(message string, args ...any) { 36 | auditMe := fmt.Sprintf(message, args...) 37 | doAudit(auditMe, zap.S().Info) 38 | } 39 | 40 | func AuditError(message string) { 41 | doAudit(message, zap.S().Error) 42 | } 43 | 44 | func AuditComponentSuccessful(component string) { 45 | message := formatComponentStatus(component, messageSuccess) 46 | Audit(message) 47 | } 48 | 49 | func AuditComponentSkipped(component string) { 50 | message := formatComponentStatus(component, messageSkipped) 51 | Audit(message) 52 | } 53 | 54 | func AuditComponentFailed(component string) { 55 | message := formatComponentStatus(component, messageFailed) 56 | Audit(message) 57 | } 58 | 59 | func doAudit(message string, logFunc func(args ...any)) { 60 | fmt.Println(message) 61 | if logFunc != nil { 62 | logFunc(message) 63 | } 64 | } 65 | 66 | func formatComponentStatus(component, status string) string { 67 | // Example output: 68 | // Component ... [STATUS] 69 | 70 | name := cases.Title(language.English).String(component) 71 | numDots := lineLength - (len(name) + 2 + 9) // 2=spaces before/after dots, 9=status msg + [] 72 | dots := strings.Repeat(".", numDots) 73 | 74 | message := fmt.Sprintf("%s %s [%s]", name, dots, status) 75 | return message 76 | } 77 | -------------------------------------------------------------------------------- /pkg/log/audit_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestFormatComponentStatus(t *testing.T) { 10 | // Tests 11 | tests := []struct { 12 | testName string 13 | component string 14 | status string 15 | expected string 16 | }{ 17 | { 18 | testName: "Success message", 19 | component: "myComponent", 20 | status: messageSuccess, 21 | expected: "Mycomponent .................. [SUCCESS]", 22 | }, 23 | { 24 | testName: "Skipped message", 25 | component: "my component", 26 | status: messageSkipped, 27 | expected: "My Component ................. [SKIPPED]", 28 | }, 29 | { 30 | testName: "Failed message", 31 | component: "MYCOMPONENT", 32 | status: messageFailed, 33 | expected: "Mycomponent .................. [FAILED ]", 34 | }, 35 | } 36 | 37 | // Run 38 | for _, test := range tests { 39 | t.Run(test.testName, func(t *testing.T) { 40 | found := formatComponentStatus(test.component, test.status) 41 | assert.Equal(t, test.expected, found) 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | func ConfigureGlobalLogger(logFilename string) { 9 | logConfig := zap.NewProductionConfig() 10 | logConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 11 | logConfig.Encoding = "console" 12 | logConfig.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder 13 | logConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 14 | logConfig.OutputPaths = []string{logFilename} 15 | 16 | logger := zap.Must(logConfig.Build()) 17 | 18 | // Set our configured logger to be accessed globally by zap.L() 19 | zap.ReplaceGlobals(logger) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/mount/mount.go: -------------------------------------------------------------------------------- 1 | package mount 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | 9 | "github.com/containers/common/pkg/subscriptions" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | ) 12 | 13 | const ( 14 | disableSuffix = ".orig" 15 | ) 16 | 17 | // DisableDefaultMounts disables default mounts for all containers by creating an empty 18 | // "mounts.conf" file at the override mount filepath provided by the user. Returns a function 19 | // that can revert to the previous mount setup if needed, or an error if a problem has occured. 20 | // If no filepath was provided, the default container override mount filepath will be used ("/etc/containers/mounts.conf"). 21 | // For more info - https://github.com/containers/common/blob/v0.57/docs/containers-mounts.conf.5.md 22 | func DisableDefaultMounts(overrideMountFilepath string) (revert func() error, err error) { 23 | mountFile := overrideMountFilepath 24 | if mountFile == "" { 25 | mountFile = subscriptions.OverrideMountsFile 26 | } 27 | 28 | disableMountFile := mountFile + disableSuffix 29 | 30 | _, err = os.Stat(mountFile) 31 | switch { 32 | case err == nil: 33 | if err = os.Rename(mountFile, disableMountFile); err != nil { 34 | return nil, fmt.Errorf("renaming existing %s mount override file: %w", mountFile, err) 35 | } 36 | 37 | if err = os.WriteFile(mountFile, []byte{}, fileio.NonExecutablePerms); err != nil { 38 | return nil, fmt.Errorf("creating empty %s mount override file: %w", mountFile, err) 39 | } 40 | 41 | return func() error { 42 | if err = os.Remove(mountFile); err != nil { 43 | return fmt.Errorf("removing empty %s file: %w", mountFile, err) 44 | } 45 | 46 | if err = os.Rename(disableMountFile, mountFile); err != nil { 47 | return fmt.Errorf("renaming original mounts.conf file from %s: %w", disableMountFile, err) 48 | } 49 | return nil 50 | }, nil 51 | case errors.Is(err, fs.ErrNotExist): 52 | if err = os.WriteFile(mountFile, []byte{}, fileio.NonExecutablePerms); err != nil { 53 | return nil, fmt.Errorf("creating empty %s mount override file: %w", mountFile, err) 54 | } 55 | 56 | return func() error { 57 | if err = os.Remove(mountFile); err != nil { 58 | return fmt.Errorf("removing empty %s file: %w", mountFile, err) 59 | } 60 | return nil 61 | }, nil 62 | default: 63 | return nil, fmt.Errorf("describing file %s: %w", mountFile, err) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/network/config_generator.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os/exec" 7 | ) 8 | 9 | type ConfigGenerator struct{} 10 | 11 | func (ConfigGenerator) GenerateNetworkConfig(configDir, outputDir string, outputWriter io.Writer) error { 12 | cmd := generateCommand(configDir, outputDir, outputWriter) 13 | if err := cmd.Run(); err != nil { 14 | return fmt.Errorf("running generate command: %w", err) 15 | } 16 | 17 | return nil 18 | } 19 | 20 | func generateCommand(configDir, outputDir string, output io.Writer) *exec.Cmd { 21 | cmd := exec.Command("nmc", "generate", 22 | "--config-dir", configDir, 23 | "--output-dir", outputDir) 24 | 25 | cmd.Stdout = output 26 | cmd.Stderr = output 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /pkg/network/config_generator_test.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func setup(t *testing.T) (inputDir, outputDir string, teardown func()) { 13 | inputConfigDir, err := os.MkdirTemp("", "eib-network-config-input-") 14 | require.NoError(t, err) 15 | 16 | outputConfigDir, err := os.MkdirTemp("", "eib-network-config-output-") 17 | require.NoError(t, err) 18 | 19 | return inputConfigDir, outputConfigDir, func() { 20 | assert.NoError(t, os.RemoveAll(outputConfigDir)) 21 | assert.NoError(t, os.RemoveAll(inputConfigDir)) 22 | } 23 | } 24 | 25 | func TestGenerateCommand(t *testing.T) { 26 | inputConfigDir, outputConfigDir, teardown := setup(t) 27 | defer teardown() 28 | 29 | var sb strings.Builder 30 | 31 | cmd := generateCommand(inputConfigDir, outputConfigDir, &sb) 32 | 33 | expectedArgs := []string{ 34 | "nmc", 35 | "generate", 36 | "--config-dir", inputConfigDir, 37 | "--output-dir", outputConfigDir, 38 | } 39 | 40 | assert.Equal(t, expectedArgs, cmd.Args) 41 | assert.Equal(t, &sb, cmd.Stdout) 42 | assert.Equal(t, &sb, cmd.Stderr) 43 | } 44 | 45 | // TODO: Set up working example once nmc is available as an RPM 46 | func TestConfigGenerator_GenerateNetworkConfig_MissingExecutable(t *testing.T) { 47 | inputConfigDir, outputConfigDir, teardown := setup(t) 48 | defer teardown() 49 | 50 | var sb strings.Builder 51 | var generator ConfigGenerator 52 | 53 | err := generator.GenerateNetworkConfig(inputConfigDir, outputConfigDir, &sb) 54 | require.Error(t, err) 55 | assert.ErrorContains(t, err, "running generate command") 56 | assert.ErrorContains(t, err, "executable file not found") 57 | } 58 | -------------------------------------------------------------------------------- /pkg/network/configurator_installer.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 7 | ) 8 | 9 | type ConfiguratorInstaller struct{} 10 | 11 | func (ConfiguratorInstaller) InstallConfigurator(sourcePath, installPath string) error { 12 | if err := fileio.CopyFile(sourcePath, installPath, fileio.ExecutablePerms); err != nil { 13 | return fmt.Errorf("copying file: %w", err) 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/network/configurator_installer_test.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | ) 12 | 13 | func TestConfiguratorInstaller_InstallConfigurator(t *testing.T) { 14 | binaryContents := []byte("network magic") 15 | 16 | srcDir, err := os.MkdirTemp("", "eib-configurator-installer-source-") 17 | require.NoError(t, err) 18 | 19 | defer func() { 20 | assert.NoError(t, os.RemoveAll(srcDir)) 21 | }() 22 | 23 | binaryPath := filepath.Join(srcDir, "nmc-x86_64") 24 | require.NoError(t, os.WriteFile(binaryPath, binaryContents, fileio.NonExecutablePerms)) 25 | 26 | destDir, err := os.MkdirTemp("", "eib-configurator-installer-dest-") 27 | require.NoError(t, err) 28 | 29 | defer func() { 30 | assert.NoError(t, os.RemoveAll(destDir)) 31 | }() 32 | 33 | tests := []struct { 34 | name string 35 | sourcePath string 36 | installPath string 37 | expectedContents []byte 38 | expectedError string 39 | }{ 40 | { 41 | name: "Failure to copy non-existing binary", 42 | sourcePath: "nmc-x86_64", 43 | expectedError: "copying file: opening source file: open nmc-x86_64: no such file or directory", 44 | }, 45 | { 46 | name: "Successfully installed binary", 47 | sourcePath: binaryPath, 48 | installPath: filepath.Join(destDir, "nmc"), 49 | expectedContents: binaryContents, 50 | }, 51 | } 52 | 53 | var installer ConfiguratorInstaller 54 | 55 | for _, test := range tests { 56 | t.Run(test.name, func(t *testing.T) { 57 | err = installer.InstallConfigurator(test.sourcePath, test.installPath) 58 | 59 | if test.expectedError != "" { 60 | require.Error(t, err) 61 | assert.EqualError(t, err, test.expectedError) 62 | return 63 | } 64 | 65 | require.NoError(t, err) 66 | 67 | contents, err := os.ReadFile(test.installPath) 68 | require.NoError(t, err) 69 | assert.Equal(t, test.expectedContents, contents) 70 | 71 | info, err := os.Stat(test.installPath) 72 | require.NoError(t, err) 73 | assert.Equal(t, fileio.ExecutablePerms, info.Mode()) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/podman/listener.go: -------------------------------------------------------------------------------- 1 | package podman 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/suse-edge/edge-image-builder/pkg/log" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | const ( 17 | podmanArgsBase = "--log-level=debug system service -t 0" 18 | podmanExec = "/usr/bin/podman" 19 | podmanListenerLogFile = "podman-system-service.log" 20 | podmanSocketPath = "/run/podman/podman.sock" 21 | ) 22 | 23 | // creates a listening service that answers API calls for Podman (https://docs.podman.io/en/v4.8.3/markdown/podman-system-service.1.html) 24 | // only way to start the service from within a container - https://github.com/containers/podman/tree/v4.8.3/pkg/bindings#starting-the-service-manually 25 | func setupAPIListener(out string) error { 26 | log.AuditInfo("Setting up Podman API listener...") 27 | 28 | logFile, err := os.Create(filepath.Join(out, podmanListenerLogFile)) 29 | if err != nil { 30 | return fmt.Errorf("creating podman listener log file: %w", err) 31 | } 32 | 33 | defer logFile.Close() 34 | 35 | cmd := preparePodmanCommand(logFile) 36 | err = cmd.Start() 37 | if err != nil { 38 | return fmt.Errorf("error running podman system service: %w", err) 39 | } 40 | 41 | return waitForPodmanSock() 42 | } 43 | 44 | func preparePodmanCommand(out io.Writer) *exec.Cmd { 45 | args := strings.Split(podmanArgsBase, " ") 46 | cmd := exec.Command(podmanExec, args...) 47 | cmd.Stdout = out 48 | cmd.Stderr = out 49 | 50 | return cmd 51 | } 52 | 53 | func waitForPodmanSock() error { 54 | const ( 55 | retries = 5 56 | sleepSeconds = 3 57 | ) 58 | 59 | zap.S().Infof("Waiting for '%s' to be created", podmanSocketPath) 60 | for i := 0; i < retries; i++ { 61 | if _, err := os.Stat(podmanSocketPath); err == nil { 62 | zap.S().Infof("'%s' file has been created successfully", podmanSocketPath) 63 | return nil 64 | } 65 | 66 | zap.S().Infof("'%s' file is not yet created, retrying in %d seconds", podmanSocketPath, sleepSeconds) 67 | time.Sleep(sleepSeconds * time.Second) 68 | } 69 | 70 | return fmt.Errorf("'%s' file was not created in the expected time", podmanSocketPath) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/registry/helm.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/suse-edge/edge-image-builder/pkg/image" 10 | ) 11 | 12 | func (r *Registry) HelmCharts() ([]*HelmCRD, error) { 13 | var crds []*HelmCRD 14 | 15 | for _, chart := range r.helmCharts { 16 | data, err := os.ReadFile(chart.localPath) 17 | if err != nil { 18 | return nil, fmt.Errorf("reading chart: %w", err) 19 | } 20 | 21 | chartContent := base64.StdEncoding.EncodeToString(data) 22 | 23 | var valuesContent []byte 24 | if chart.ValuesFile != "" { 25 | valuesPath := filepath.Join(r.helmValuesDir, chart.ValuesFile) 26 | valuesContent, err = os.ReadFile(valuesPath) 27 | if err != nil { 28 | return nil, fmt.Errorf("reading values content: %w", err) 29 | } 30 | } 31 | 32 | crd := NewHelmCRD(&chart.HelmChart, chartContent, string(valuesContent), chart.repositoryURL) 33 | crds = append(crds, crd) 34 | } 35 | 36 | return crds, nil 37 | } 38 | 39 | func (r *Registry) helmChartImages() ([]string, error) { 40 | var containerImages []string 41 | 42 | for _, chart := range r.helmCharts { 43 | var valuesPath string 44 | if chart.ValuesFile != "" { 45 | valuesPath = filepath.Join(r.helmValuesDir, chart.ValuesFile) 46 | } 47 | 48 | images, err := r.getChartContainerImages(&chart.HelmChart, chart.localPath, valuesPath, r.kubeVersion) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | containerImages = append(containerImages, images...) 54 | } 55 | 56 | return containerImages, nil 57 | } 58 | 59 | func (r *Registry) getChartContainerImages(chart *image.HelmChart, chartPath, valuesPath, kubeVersion string) ([]string, error) { 60 | chartResources, err := r.helmClient.Template(chart.Name, chartPath, chart.Version, valuesPath, kubeVersion, chart.TargetNamespace, chart.APIVersions) 61 | if err != nil { 62 | return nil, fmt.Errorf("templating chart: %w", err) 63 | } 64 | 65 | containerImages := map[string]bool{} 66 | for _, resource := range chartResources { 67 | extractManifestImages(resource, containerImages) 68 | } 69 | 70 | var images []string 71 | for i := range containerImages { 72 | images = append(images, i) 73 | } 74 | 75 | return images, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/registry/helm_crd.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/suse-edge/edge-image-builder/pkg/image" 7 | ) 8 | 9 | const ( 10 | helmChartAPIVersion = "helm.cattle.io/v1" 11 | helmChartKind = "HelmChart" 12 | helmChartSource = "edge-image-builder" 13 | helmBackoffLimit = 20 14 | ) 15 | 16 | type HelmCRD struct { 17 | APIVersion string `yaml:"apiVersion"` 18 | Kind string `yaml:"kind"` 19 | Metadata struct { 20 | Name string `yaml:"name"` 21 | Namespace string `yaml:"namespace,omitempty"` 22 | Annotations map[string]string `yaml:"annotations"` 23 | } `yaml:"metadata"` 24 | Spec struct { 25 | Version string `yaml:"version"` 26 | ValuesContent string `yaml:"valuesContent,omitempty"` 27 | ChartContent string `yaml:"chartContent"` 28 | TargetNamespace string `yaml:"targetNamespace,omitempty"` 29 | CreateNamespace bool `yaml:"createNamespace,omitempty"` 30 | BackOffLimit int `yaml:"backOffLimit"` 31 | } `yaml:"spec"` 32 | } 33 | 34 | func NewHelmCRD(chart *image.HelmChart, chartContent, valuesContent, repositoryURL string) *HelmCRD { 35 | // Some OCI registries (incl. oci://registry.suse.com/edge) use a `-chart` suffix 36 | // in the names of the charts which may conflict with .Release.Name references. 37 | name := strings.TrimSuffix(chart.Name, "-chart") 38 | if chart.ReleaseName != "" { 39 | name = chart.ReleaseName 40 | } 41 | 42 | return &HelmCRD{ 43 | APIVersion: helmChartAPIVersion, 44 | Kind: helmChartKind, 45 | Metadata: struct { 46 | Name string `yaml:"name"` 47 | Namespace string `yaml:"namespace,omitempty"` 48 | Annotations map[string]string `yaml:"annotations"` 49 | }{ 50 | Name: name, 51 | Namespace: chart.InstallationNamespace, 52 | Annotations: map[string]string{ 53 | "edge.suse.com/source": helmChartSource, 54 | "edge.suse.com/repository-url": repositoryURL, 55 | }, 56 | }, 57 | Spec: struct { 58 | Version string `yaml:"version"` 59 | ValuesContent string `yaml:"valuesContent,omitempty"` 60 | ChartContent string `yaml:"chartContent"` 61 | TargetNamespace string `yaml:"targetNamespace,omitempty"` 62 | CreateNamespace bool `yaml:"createNamespace,omitempty"` 63 | BackOffLimit int `yaml:"backOffLimit"` 64 | }{ 65 | Version: chart.Version, 66 | ValuesContent: valuesContent, 67 | ChartContent: chartContent, 68 | TargetNamespace: chart.TargetNamespace, 69 | CreateNamespace: chart.CreateNamespace, 70 | BackOffLimit: helmBackoffLimit, 71 | }, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/registry/manifests.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "slices" 11 | 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | func (r *Registry) manifestImages() ([]string, error) { 16 | containerImages := make(map[string]bool) 17 | 18 | entries, err := os.ReadDir(r.manifestsDir) 19 | if err != nil { 20 | if errors.Is(err, fs.ErrNotExist) { 21 | return nil, nil 22 | } 23 | return nil, fmt.Errorf("reading manifest dir: %w", err) 24 | } 25 | 26 | for _, entry := range entries { 27 | path := filepath.Join(r.manifestsDir, entry.Name()) 28 | 29 | resources, err := readManifest(path) 30 | if err != nil { 31 | return nil, fmt.Errorf("reading manifest '%s': %w", path, err) 32 | } 33 | 34 | for _, resource := range resources { 35 | extractManifestImages(resource, containerImages) 36 | } 37 | } 38 | 39 | var images []string 40 | 41 | for imageName := range containerImages { 42 | images = append(images, imageName) 43 | } 44 | 45 | return images, nil 46 | } 47 | 48 | func readManifest(manifestPath string) ([]map[string]any, error) { 49 | manifestFile, err := os.Open(manifestPath) 50 | if err != nil { 51 | return nil, fmt.Errorf("opening manifest: %w", err) 52 | } 53 | defer manifestFile.Close() 54 | 55 | var resources []map[string]any 56 | 57 | decoder := yaml.NewDecoder(manifestFile) 58 | for { 59 | var r map[string]any 60 | 61 | if err = decoder.Decode(&r); err != nil { 62 | if errors.Is(err, io.EOF) { 63 | break 64 | } 65 | return nil, fmt.Errorf("unmarshalling manifest: %w", err) 66 | } 67 | 68 | resources = append(resources, r) 69 | } 70 | 71 | if len(resources) == 0 { 72 | return nil, fmt.Errorf("invalid manifest") 73 | } 74 | 75 | return resources, nil 76 | } 77 | 78 | func extractManifestImages(resource map[string]any, images map[string]bool) { 79 | var k8sKinds = []string{ 80 | "Pod", 81 | "Deployment", 82 | "StatefulSet", 83 | "DaemonSet", 84 | "ReplicaSet", 85 | "Job", 86 | "CronJob", 87 | } 88 | 89 | kind, _ := resource["kind"].(string) 90 | if !slices.Contains(k8sKinds, kind) { 91 | return 92 | } 93 | 94 | var findImages func(data any) 95 | 96 | findImages = func(data any) { 97 | switch t := data.(type) { 98 | case map[string]any: 99 | for k, v := range t { 100 | if k == "image" { 101 | if imageName, ok := v.(string); ok { 102 | images[imageName] = true 103 | } 104 | } 105 | findImages(v) 106 | } 107 | case []any: 108 | for _, v := range t { 109 | findImages(v) 110 | } 111 | } 112 | } 113 | 114 | findImages(resource) 115 | } 116 | -------------------------------------------------------------------------------- /pkg/registry/manifests_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package registry 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 13 | "github.com/suse-edge/edge-image-builder/pkg/image" 14 | ) 15 | 16 | func TestManifestImages(t *testing.T) { 17 | // Setup 18 | localManifestsDir := "local-manifests" 19 | 20 | require.NoError(t, os.Mkdir(localManifestsDir, 0o755)) 21 | defer func() { 22 | assert.NoError(t, os.RemoveAll(localManifestsDir)) 23 | }() 24 | 25 | sourceManifest := filepath.Join("testdata", "sample-crd.yaml") 26 | destinationManifest := filepath.Join(localManifestsDir, "sample-crd.yaml") 27 | 28 | require.NoError(t, fileio.CopyFile(sourceManifest, destinationManifest, fileio.NonExecutablePerms)) 29 | 30 | buildDir := filepath.Join(os.TempDir(), "_manifests-integration") 31 | require.NoError(t, os.MkdirAll(buildDir, os.ModePerm)) 32 | defer func() { 33 | assert.NoError(t, os.RemoveAll(buildDir)) 34 | }() 35 | 36 | ctx := &image.Context{ 37 | BuildDir: buildDir, 38 | ImageDefinition: &image.Definition{ 39 | Kubernetes: image.Kubernetes{ 40 | Manifests: image.Manifests{ 41 | URLs: []string{"https://k8s.io/examples/application/nginx-app.yaml"}, 42 | }, 43 | }, 44 | }, 45 | } 46 | 47 | registry, err := New(ctx, localManifestsDir, nil, "") 48 | require.NoError(t, err) 49 | 50 | // Test 51 | containerImages, err := registry.manifestImages() 52 | 53 | // Verify 54 | require.NoError(t, err) 55 | assert.ElementsMatch(t, []string{ 56 | "custom-api:1.2.3", 57 | "mysql:5.7", 58 | "redis:6.0", 59 | "nginx:latest", 60 | "node:14", 61 | "nginx:1.14.2", 62 | }, containerImages) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/suse-edge/edge-image-builder/pkg/fileio" 11 | "github.com/suse-edge/edge-image-builder/pkg/image" 12 | ) 13 | 14 | func TestRegistry_New_InvalidManifestURL(t *testing.T) { 15 | buildDir := filepath.Join(os.TempDir(), "_build-registry-error") 16 | require.NoError(t, os.MkdirAll(buildDir, os.ModePerm)) 17 | defer func() { 18 | assert.NoError(t, os.RemoveAll(buildDir)) 19 | }() 20 | 21 | ctx := &image.Context{ 22 | BuildDir: buildDir, 23 | ImageDefinition: &image.Definition{ 24 | Kubernetes: image.Kubernetes{ 25 | Manifests: image.Manifests{ 26 | URLs: []string{"k8s.io/examples/application/nginx-app.yaml"}}, 27 | }, 28 | }, 29 | } 30 | 31 | _, err := New(ctx, "", nil, "") 32 | require.Error(t, err) 33 | 34 | assert.ErrorContains(t, err, "downloading manifest 'k8s.io/examples/application/nginx-app.yaml'") 35 | assert.ErrorContains(t, err, "unsupported protocol scheme") 36 | } 37 | 38 | func TestRegistry_ContainerImages(t *testing.T) { 39 | manifestsDir := filepath.Join(os.TempDir(), "_manifests") 40 | require.NoError(t, os.MkdirAll(manifestsDir, os.ModePerm)) 41 | defer func() { 42 | assert.NoError(t, os.RemoveAll(manifestsDir)) 43 | }() 44 | 45 | require.NoError(t, fileio.CopyFile("testdata/sample-crd.yaml", filepath.Join(manifestsDir, "sample-crd.yaml"), fileio.NonExecutablePerms)) 46 | 47 | registry := Registry{ 48 | embeddedImages: []image.ContainerImage{ 49 | { 50 | Name: "hello-world", 51 | }, 52 | { 53 | Name: "nginx:latest", 54 | }, 55 | }, 56 | manifestsDir: manifestsDir, 57 | helmCharts: []*helmChart{ 58 | { 59 | HelmChart: image.HelmChart{ 60 | Name: "apache", 61 | }, 62 | }, 63 | }, 64 | helmClient: mockHelmClient{ 65 | templateFunc: func(chart, repository, version, valuesFilePath, kubeVersion, targetNamespace string, apiVersions []string) ([]map[string]any, error) { 66 | return []map[string]any{ 67 | { 68 | "kind": "Deployment", 69 | "image": "httpd", 70 | }, 71 | { 72 | "kind": "Service", 73 | }, 74 | }, nil 75 | }, 76 | }, 77 | } 78 | 79 | images, err := registry.ContainerImages() 80 | require.NoError(t, err) 81 | 82 | assert.ElementsMatch(t, images, []string{ 83 | // embedded images 84 | "hello-world", 85 | "nginx:latest", 86 | // manifest images 87 | "node:14", 88 | "custom-api:1.2.3", 89 | "mysql:5.7", 90 | "redis:6.0", 91 | "nginx:1.14.2", 92 | // chart images 93 | "httpd", 94 | }) 95 | } 96 | 97 | func TestDeduplicateContainerImages(t *testing.T) { 98 | embeddedImages := []image.ContainerImage{ 99 | { 100 | Name: "hello-world:latest", 101 | }, 102 | { 103 | Name: "embedded-image:1.0.0", 104 | }, 105 | } 106 | 107 | manifestImages := []string{ 108 | "hello-world:latest", 109 | "manifest-image:1.0.0", 110 | } 111 | 112 | chartImages := []string{ 113 | "hello-world:latest", 114 | "chart-image:1.0.0", 115 | "chart-image:1.0.0", 116 | "chart-image:1.0.1", 117 | "chart-image:2.0.0", 118 | } 119 | 120 | assert.ElementsMatch(t, []string{ 121 | "hello-world:latest", 122 | "embedded-image:1.0.0", 123 | "manifest-image:1.0.0", 124 | "chart-image:1.0.0", 125 | "chart-image:1.0.1", 126 | "chart-image:2.0.0", 127 | }, deduplicateContainerImages(embeddedImages, manifestImages, chartImages)) 128 | } 129 | -------------------------------------------------------------------------------- /pkg/registry/testdata/empty-crd.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suse-edge/edge-image-builder/b74c867dd3aa20e00caa18c4b52f9563886419eb/pkg/registry/testdata/empty-crd.yaml -------------------------------------------------------------------------------- /pkg/registry/testdata/invalid-crd.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | - kind: invalid manifest -------------------------------------------------------------------------------- /pkg/registry/testdata/sample-crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "custom.example.com/v1" 2 | kind: Deployment 3 | metadata: 4 | name: my-complex-app 5 | labels: 6 | app: complex-application 7 | spec: 8 | components: 9 | - name: web-frontend 10 | type: frontend 11 | containers: 12 | - name: nginx-container 13 | image: nginx:latest 14 | - name: frontend-builder 15 | image: node:14 16 | settings: 17 | resources: 18 | limits: 19 | cpu: "500m" 20 | memory: "256Mi" 21 | - name: api-server 22 | type: backend 23 | containers: 24 | - name: api-container 25 | image: custom-api:1.2.3 26 | settings: 27 | resources: 28 | limits: 29 | cpu: "1" 30 | memory: "512Mi" 31 | database: 32 | type: sql 33 | version: "5.7" 34 | containers: 35 | - name: sql-container 36 | image: mysql:5.7 37 | settings: 38 | storage: 39 | size: "10Gi" 40 | caching: 41 | type: redis 42 | containers: 43 | - name: redis-container 44 | image: redis:6.0 45 | - name: sql-container 46 | image: mysql:5.7 47 | 48 | settings: 49 | memory: 50 | max: "256Mi" 51 | --- 52 | apiVersion: apps/v1 53 | kind: Deployment 54 | metadata: 55 | name: my-nginx 56 | labels: 57 | app: nginx 58 | spec: 59 | replicas: 3 60 | selector: 61 | matchLabels: 62 | app: nginx 63 | template: 64 | metadata: 65 | labels: 66 | app: nginx 67 | spec: 68 | containers: 69 | - name: nginx 70 | image: nginx:1.14.2 71 | ports: 72 | - containerPort: 80 -------------------------------------------------------------------------------- /pkg/rpm/repo.go: -------------------------------------------------------------------------------- 1 | package rpm 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "go.uber.org/zap" 11 | ) 12 | 13 | const ( 14 | createRepoExec = "/usr/bin/createrepo" 15 | createRepoLog = "createrepo.log" 16 | ) 17 | 18 | type RepoCreator struct { 19 | logOut string 20 | } 21 | 22 | func NewRepoCreator(logOut string) *RepoCreator { 23 | return &RepoCreator{ 24 | logOut: logOut, 25 | } 26 | } 27 | 28 | func (r *RepoCreator) Create(path string) error { 29 | zap.S().Infof("Creating RPM repository from '%s'", path) 30 | 31 | logFile, err := os.Create(filepath.Join(r.logOut, createRepoLog)) 32 | if err != nil { 33 | return fmt.Errorf("generating createrepo log file: %w", err) 34 | } 35 | defer logFile.Close() 36 | 37 | cmd := prepareRepoCommand(path, logFile) 38 | err = cmd.Run() 39 | if err != nil { 40 | return fmt.Errorf("error running createrepo: %w", err) 41 | } 42 | 43 | zap.L().Info("RPM repository created successfully") 44 | return nil 45 | } 46 | 47 | func prepareRepoCommand(path string, w io.Writer) *exec.Cmd { 48 | cmd := exec.Command(createRepoExec, path) 49 | cmd.Stdout = w 50 | cmd.Stderr = w 51 | 52 | return cmd 53 | } 54 | -------------------------------------------------------------------------------- /pkg/rpm/resolver/templates/Dockerfile.tpl: -------------------------------------------------------------------------------- 1 | # Template Fields 2 | # BaseImage - image to use as base for the build of this image 3 | # FromRPMPath - path to the custom RPM directory relative to the resolver image build context in the EIB container 4 | # ToRPMPath - path to the custom RPM directory relative to the resolver image 5 | # FromGPGPath - path to the directory holding the GPG keys for the custom RPMs relative to the resolver image build context in the EIB container 6 | # ToGPGPath - path to the directory holding the GPG keys for the custom RPMs relative to the resolver image 7 | # RPMResolutionScriptName - name of the RPM resolution script 8 | FROM {{ .BaseImage }} 9 | 10 | COPY {{ .RPMResolutionScriptName }} {{ .RPMResolutionScriptName }} 11 | 12 | {{ if and .FromRPMPath .ToRPMPath -}} 13 | COPY {{ .FromRPMPath }} {{ .ToRPMPath }} 14 | {{ if and .FromGPGPath .ToGPGPath -}} 15 | COPY {{ .FromGPGPath }} {{ .ToGPGPath }} 16 | {{ end -}} 17 | {{ end }} 18 | 19 | RUN ./{{ .RPMResolutionScriptName }} 20 | 21 | CMD ["/bin/bash"] -------------------------------------------------------------------------------- /pkg/rpm/resolver/templates/prepare-tarball.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Template Fields 5 | # WorkDir - directory from where this script will be running 6 | # ImgPath - path to the image that will be prepared 7 | # ImgType - type of the image (either .iso, or .raw) 8 | # ArchiveName - name of the virtual disk archive that will be created from this image 9 | # LUKSKey - The key necessary for modifying encrypted raw images 10 | 11 | WORK_DIR={{.WorkDir}} 12 | IMG_PATH={{.ImgPath}} 13 | 14 | # Set the LUKS key flag for encrypted images 15 | LUKSFLAG="" 16 | {{ if .LUKSKey }} 17 | LUKSFLAG="--key all:key:{{ .LUKSKey }}" 18 | {{ end }} 19 | 20 | # Make the necessarry adaptations for aarch64 21 | if [[ {{ .Arch }} == "aarch64" ]]; then 22 | export LIBGUESTFS_BACKEND_SETTINGS=force_tcg 23 | fi 24 | 25 | {{ if eq .ImgType "iso" -}} 26 | xorriso -osirrox on -indev $IMG_PATH extract / $WORK_DIR/iso-root/ 27 | 28 | ISO_ROOT=$WORK_DIR/iso-root 29 | cd $ISO_ROOT 30 | 31 | ISO_SQUASHFS=`find $ISO_ROOT -name "*.squashfs"` 32 | if [ `wc -w <<< $ISO_SQUASHFS` -ne 1 ]; then 33 | echo "Unexpected number of '.squashfs' files: $ISO_SQUASHFS" 34 | exit 1 35 | fi 36 | 37 | UNSQUASHFS_DIR=$ISO_ROOT/squashfs-root 38 | unsquashfs -d $UNSQUASHFS_DIR $ISO_SQUASHFS 39 | 40 | cd $UNSQUASHFS_DIR 41 | 42 | RAW_FILE=`find $UNSQUASHFS_DIR -name "*.raw"` 43 | if [ `wc -w <<< $RAW_FILE` -ne 1 ]; then 44 | echo "Unexpected number of '.raw' files: $RAW_FILE" 45 | exit 1 46 | fi 47 | 48 | # Test the block size of the base image and adapt to suit either 512/4096 byte images 49 | BLOCKSIZE=512 50 | if ! guestfish -i --blocksize=$BLOCKSIZE -a $RAW_FILE $LUKSFLAG echo "[INFO] 512 byte sector check successful."; then 51 | echo "[WARN] Failed to access image with 512 byte sector size, trying 4096 bytes." 52 | BLOCKSIZE=4096 53 | fi 54 | 55 | virt-tar-out --blocksize=$BLOCKSIZE -a $RAW_FILE / - | pigz --best > $WORK_DIR/{{.ArchiveName}} 56 | {{ else }} 57 | 58 | # Test the block size of the base image and adapt to suit either 512/4096 byte images 59 | BLOCKSIZE=512 60 | if ! guestfish -i --blocksize=$BLOCKSIZE -a $IMG_PATH $LUKSFLAG echo "[INFO] 512 byte sector check successful."; then 61 | echo "[WARN] Failed to access image with 512 byte sector size, trying 4096 bytes." 62 | BLOCKSIZE=4096 63 | fi 64 | 65 | virt-tar-out --blocksize=$BLOCKSIZE -a $IMG_PATH $LUKSFLAG / - | pigz --best > $WORK_DIR/{{.ArchiveName}} 66 | 67 | {{ end }} 68 | -------------------------------------------------------------------------------- /pkg/rpm/resolver/templates/rpm-resolution.sh.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Template Fields 5 | # RegCode - scc.suse.com registration code 6 | # AddRepo - additional third-party repositories that will be used in the resolution process 7 | # CacheDir - zypper cache directory where all rpm dependencies will be downloaded to 8 | # PKGList - list of packages for which to do the dependency resolution 9 | # LocalRPMList - list of local RPMs for which dependency resolution has to be done 10 | # LocalGPGList - list of local GPG keys that will be imported in the resolver image 11 | # NoGPGCheck - when set to true skips the GPG validation for all third-party repositories and local RPMs 12 | # Arch - sets the architecture of the rpm packages to pull 13 | # EnableExtras - registers the SL-Micro-Extras repo for use in resolution 14 | 15 | {{ if ne .RegCode "" }} 16 | suseconnect -r {{ .RegCode }} 17 | {{ if $.EnableExtras -}} 18 | VERSION=$(awk '/VERSION=/' /etc/os-release | cut -d'"' -f2) 19 | suseconnect -p SL-Micro-Extras/$VERSION/{{ .Arch }} 20 | {{ end -}} 21 | zypper ref 22 | trap "suseconnect -d" EXIT 23 | {{ end -}} 24 | 25 | {{- range $index, $repo := .AddRepo }} 26 | 27 | {{- $gpgCheck := "" -}} 28 | {{- if $.NoGPGCheck -}} 29 | {{ $gpgCheck = "--no-gpgcheck" }} 30 | {{- else if .Unsigned -}} 31 | {{ $gpgCheck = "--gpgcheck-allow-unsigned-repo" }} 32 | {{- end -}} 33 | 34 | zypper ar {{ $gpgCheck }} -f {{ .URL }} addrepo {{- $index }} 35 | 36 | {{ end -}} 37 | 38 | {{ if and .LocalGPGList (not .NoGPGCheck) }} 39 | rpm --import {{ .LocalGPGList }} 40 | {{ end -}} 41 | 42 | {{ if and .LocalRPMList (not .NoGPGCheck) }} 43 | rpm -Kv {{ .LocalRPMList }} 44 | {{ end -}} 45 | 46 | mkdir -p {{.CacheDir}} 47 | 48 | zypper \ 49 | --pkg-cache-dir {{.CacheDir}} \ 50 | --gpg-auto-import-keys \ 51 | {{ if .NoGPGCheck -}} 52 | --no-gpg-checks \ 53 | {{ end -}} 54 | install -y \ 55 | --download-only \ 56 | --force-resolution \ 57 | --auto-agree-with-licenses \ 58 | --allow-vendor-change \ 59 | -n {{.PKGList}} {{.LocalRPMList}} 60 | 61 | touch {{.CacheDir}}/zypper-success 62 | -------------------------------------------------------------------------------- /pkg/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "text/template" 8 | ) 9 | 10 | func Parse(name string, contents string, templateData any) (string, error) { 11 | if templateData == nil { 12 | return "", fmt.Errorf("template data not provided") 13 | } 14 | 15 | funcs := template.FuncMap{"join": strings.Join} 16 | 17 | tmpl, err := template.New(name).Funcs(funcs).Parse(contents) 18 | if err != nil { 19 | return "", fmt.Errorf("parsing contents: %w", err) 20 | } 21 | 22 | var buff bytes.Buffer 23 | if err = tmpl.Execute(&buff, templateData); err != nil { 24 | return "", fmt.Errorf("applying template: %w", err) 25 | } 26 | 27 | return buff.String(), nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParse(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | templateName string 14 | contents string 15 | templateData any 16 | expectedOutput string 17 | expectedErr string 18 | }{ 19 | { 20 | name: "Template is successfully processed", 21 | templateName: "valid-template", 22 | contents: "{{.Foo}} and {{.Bar}}", 23 | templateData: struct { 24 | Foo string 25 | Bar string 26 | }{ 27 | Foo: "ooF", 28 | Bar: "raB", 29 | }, 30 | expectedOutput: "ooF and raB", 31 | }, 32 | { 33 | name: "Templating fails due to missing data", 34 | templateName: "missing-data", 35 | contents: "{{.Foo}} and {{.Bar}}", 36 | expectedErr: "template data not provided", 37 | }, 38 | { 39 | name: "Templating fails due to invalid syntax", 40 | templateName: "invalid-syntax", 41 | contents: "{{.Foo and ", 42 | templateData: struct{}{}, 43 | expectedErr: "parsing contents: template: invalid-syntax:1: unclosed action", 44 | }, 45 | { 46 | name: "Templating fails due to missing field", 47 | templateName: "invalid-data", 48 | contents: "{{.Foo}} and {{.Bar}}", 49 | templateData: struct { 50 | Foo string 51 | }{ 52 | Foo: "ooF", 53 | }, 54 | expectedErr: "applying template: template: invalid-data:1:15: " + 55 | "executing \"invalid-data\" at <.Bar>: can't evaluate field Bar in type struct { Foo string }", 56 | }, 57 | } 58 | 59 | for _, test := range tests { 60 | t.Run(test.name, func(t *testing.T) { 61 | data, err := Parse(test.templateName, test.contents, test.templateData) 62 | 63 | if test.expectedErr != "" { 64 | assert.EqualError(t, err, test.expectedErr) 65 | assert.Equal(t, "", data) 66 | } else { 67 | require.Nil(t, err) 68 | assert.Equal(t, test.expectedOutput, data) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | "slices" 7 | ) 8 | 9 | const ( 10 | version10 = "1.0" 11 | version11 = "1.1" 12 | version12 = "1.2" 13 | ) 14 | 15 | var SupportedSchemaVersions = []string{version10, version11, version12} 16 | 17 | var version string 18 | 19 | func GetEibVersion() string { 20 | if version != "" { 21 | return version 22 | } 23 | 24 | if info, ok := debug.ReadBuildInfo(); ok { 25 | for _, setting := range info.Settings { 26 | if setting.Key == "vcs.revision" { 27 | return fmt.Sprintf("git-%s", setting.Value) 28 | } 29 | } 30 | } 31 | 32 | return "Unknown" 33 | } 34 | 35 | func IsSchemaVersionSupported(version string) bool { 36 | return slices.Contains(SupportedSchemaVersions, version) 37 | } 38 | --------------------------------------------------------------------------------