diff --git a/env.go b/env.go new file mode 100644 index 0000000..cd546c0 --- /dev/null +++ b/env.go @@ -0,0 +1,229 @@ +// um env is a feature for ultramarine bootc specifically that lets you build local derivations "transactionally" +// +// it works similar to what you would do with `cargo add` or `pnpm add` would do, as in it: +// +// - adds the desired system package into a manifest file (`environment.toml`) +// - rebuilds the system derivation with the changes using Podman +// - optionally, attempt to apply the changes to the running system (unstable, may not work depending on the system's state) +// - use `bootc switch` to apply the update transactionally, marking the new local build as the next default +// +// Requires Podman to be already installed on the base image, or installed temporarily on the ephemeral environment (via `bootc usr-overlay`) + +package main + +// import ( +// "fmt" + +// "github.com/charmbracelet/huh" +// "github.com/urfave/cli/v2" +// "github.com/BurntSushi/toml" +// ) +// + +import ( + "fmt" + "os" + "os/exec" + + "github.com/Ultramarine-Linux/um/env" + "github.com/Ultramarine-Linux/um/pkg/util" + "github.com/charmbracelet/huh" + "github.com/urfave/cli/v2" +) + +// todo: don't just print image name lol +func envStatus(c *cli.Context) error { + image, err := env.GetBootcImage() + if err != nil { + return err + } + + fmt.Println("Bootc image:", image) + return nil +} + +func envApplyChanges(c *cli.Context) error { + fmt.Println("Applying changes...") + + manifest, err := env.LoadEnvManifest() + if err != nil { + return err + } + + err = manifest.ApplyChanges() + if err != nil { + return err + } + + fmt.Println("Changes applied successfully.") + + return nil +} + +// initializes a new environment by creating an environment.toml and a template Containerfile +func envInit(c *cli.Context) error { + var confirmed bool + + baseImage, err := env.GetBootcImage() + if err != nil { + return err + } + + if err := huh.NewConfirm(). + Title("Initialize the local bootc derivation?"). + Description(fmt.Sprintf("This will create environment.toml and a template Containerfile based on `%s` at `%s`."+ + "\n\n"+ + "The system bootc image will be switched to `%s` and updates must now be managed via `um env update`.", baseImage, env.UmEnvContext, env.UmEnvManagedImage)). + Affirmative("Initialize"). + Negative("Cancel"). + Value(&confirmed). + Run(); err != nil { + return err + } + + if !confirmed { + fmt.Println("Aborting...") + return nil + } + + if err := env.InitEnvironment(baseImage); err != nil { + return err + } + + fmt.Println("Initialized environment in", env.UmEnvContext) + + fmt.Println("Building initial derivation...") + + if err := envBuild(c); err != nil { + return err + } + + fmt.Println("Initial derivation built successfully, switching to um managed image...") + + if err := env.EnvBootcSwitch(); err != nil { + return err + } + + return nil +} + +func envBuild(c *cli.Context) error { + util.SudoIfNeeded([]string{}) + manifest, err := env.LoadEnvManifest() + if err != nil { + return err + } + + if err := manifest.BuildContainerfile(); err != nil { + return err + } + + fmt.Println("Containerfile built successfully.") + + return nil +} + +func handleApplyLive(c *cli.Context) error { + var applyLive bool + if c.Bool("apply-live") { + applyLive = true + } + + if applyLive { + fmt.Println("Applying changes live...") + + // enable bootc usr-overlay + + if err := exec.Command("bootc", "usr-overlay").Run(); err != nil { + return err + } + + if err := envApplyChanges(c); err != nil { + return err + } + } + + return nil +} + +// add a package to the environment +func envAddPackage(c *cli.Context) error { + util.SudoIfNeeded([]string{}) + manifest, err := env.LoadEnvManifest() + if err != nil { + return err + } + + for _, pkg := range c.Args().Slice() { + if manifest.AddPackage(pkg) { + fmt.Println("Adding package:", pkg) + } else { + fmt.Println("Package already exists:", pkg) + } + } + + if err := manifest.Save(); err != nil { + return err + } + + if err := handleApplyLive(c); err != nil { + return err + } + + fmt.Println("Packages added successfully.") + + fmt.Println("Added packages successfully. Commit pending changes with `um env update`") + + return nil +} + +func envRemovePackage(c *cli.Context) error { + util.SudoIfNeeded([]string{}) + manifest, err := env.LoadEnvManifest() + if err != nil { + return err + } + + for _, pkg := range c.Args().Slice() { + if manifest.RemovePackage(pkg) { + fmt.Println("Removed package from install list:", pkg) + } else { + fmt.Println("Adding package to removal list:", pkg) + } + } + + if err := manifest.Save(); err != nil { + return err + } + + if err := handleApplyLive(c); err != nil { + return err + } + + fmt.Println("Packages removed successfully.") + fmt.Println("Commit pending changes with `um env update`") + + return nil +} + +func envUpdate(c *cli.Context) error { + util.SudoIfNeeded([]string{}) + + if err := envBuild(c); err != nil { + return err + } + + fmt.Println("Rebuilt environment image, applying changes with bootc") + + // bootc update actually pulls straight from containers-storage, + // so we can simply just do this instead of bootc switch again, assuming + // um env is already initialized + bootcCmd := exec.Command("bootc", "update") + bootcCmd.Stdout = os.Stdout + bootcCmd.Stderr = os.Stderr + if err := bootcCmd.Run(); err != nil { + return err + } + + return nil +} diff --git a/env/Containerfile.gotmpl b/env/Containerfile.gotmpl new file mode 100644 index 0000000..e6dad30 --- /dev/null +++ b/env/Containerfile.gotmpl @@ -0,0 +1,9 @@ +FROM {{ .BaseImage }} + +# Copy environment files into the image for reproducibility +RUN mkdir -p /var/um/env +COPY . /var/um/env + +# Insert your changes here, the following RUN directive commits changes from environment.toml +RUN --mount=type=cache,target=/var/cache \ + um env apply-changes diff --git a/env/main.go b/env/main.go new file mode 100644 index 0000000..f325b8b --- /dev/null +++ b/env/main.go @@ -0,0 +1,234 @@ +// um env library stuff + +package env + +import ( + _ "embed" + "log" + "os" + "os/exec" + "text/template" + + "slices" + + "github.com/BurntSushi/toml" +) + +const UmEnvContext = "/var/um/env" +const UmEnvManifest = UmEnvContext + "/environment.toml" +const UmEnvContainerfile = UmEnvContext + "/Containerfile" +const UmEnvManagedImage = "localhost/um-env" + +//go:embed Containerfile.gotmpl +var containerfileTemplateSource string + +var containerfileTemplate = template.Must(template.New("Containerfile").Parse(containerfileTemplateSource)) + +// stuff to do in the environment +type EnvManifest struct { + // [packages] + Packages Packages `toml:"packages"` +} + +type Packages struct { + Install []string `toml:"install"` + Remove []string `toml:"remove"` + Reinstall []string `toml:"reinstall"` +} + +type containerfileTemplateData struct { + BaseImage string +} + +func containsPackage(packages []string, packageName string) bool { + return slices.Contains(packages, packageName) +} + +func removePackage(packages []string, packageName string) []string { + return slices.DeleteFunc(packages, func(existing string) bool { + return existing == packageName + }) +} + +func InitEnvironment(baseImage string) (err error) { + if err := os.MkdirAll(UmEnvContext, 0o755); err != nil { + return err + } + + manifest := EnvManifest{ + Packages: Packages{ + Install: []string{}, + Remove: []string{}, + Reinstall: []string{}, + }, + } + + manifestFile, err := os.OpenFile(UmEnvManifest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + defer func() { + if closeErr := manifestFile.Close(); closeErr != nil { + if err == nil { + err = closeErr + } else { + log.Printf("failed to close %s: %v", UmEnvManifest, closeErr) + } + } + }() + + if err := toml.NewEncoder(manifestFile).Encode(manifest); err != nil { + return err + } + + containerfile, err := os.OpenFile(UmEnvContainerfile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + defer func() { + if closeErr := containerfile.Close(); closeErr != nil { + if err == nil { + err = closeErr + } else { + log.Printf("failed to close %s: %v", UmEnvContainerfile, closeErr) + } + } + }() + + if err := containerfileTemplate.Execute(containerfile, containerfileTemplateData{ + BaseImage: baseImage, + }); err != nil { + return err + } + + return nil +} + +func LoadEnvManifest() (*EnvManifest, error) { + // load from file + data, err := os.ReadFile(UmEnvManifest) + if err != nil { + return nil, err + } + // set CWD + if err := os.Chdir(UmEnvContext); err != nil { + return nil, err + } + + var manifest EnvManifest + if err := toml.Unmarshal(data, &manifest); err != nil { + return nil, err + } + + return &manifest, nil +} + +// saves the manifest to the specified path +func (e *EnvManifest) Save() error { + data, err := toml.Marshal(e) + if err != nil { + return err + } + return os.WriteFile(UmEnvManifest, data, 0o644) +} + +func (e *EnvManifest) AddPackage(packageName string) bool { + if containsPackage(e.Packages.Install, packageName) { + return false + } + + e.Packages.Install = append(e.Packages.Install, packageName) + + return true +} + +func (e *EnvManifest) ReinstallPackage(packageName string) bool { + if containsPackage(e.Packages.Reinstall, packageName) { + return false + } + + e.Packages.Reinstall = append(e.Packages.Reinstall, packageName) + + return true +} + +func (e *EnvManifest) RemovePackage(packageName string) bool { + // if package is in the add list, simply remove it + if containsPackage(e.Packages.Install, packageName) { + e.Packages.Install = removePackage(e.Packages.Install, packageName) + return true + } + + // if package is not, then add it to the remove list + if !containsPackage(e.Packages.Remove, packageName) { + e.Packages.Remove = append(e.Packages.Remove, packageName) + } + + return false +} + +func appendPackageAction(args []string, action string, packages []string) []string { + if len(packages) == 0 { + return args + } + + args = append(args, "--action="+action) + args = append(args, packages...) + + return args +} + +func (p *Packages) CommitTransaction() error { + args := []string{"do", "-y"} + args = appendPackageAction(args, "install", p.Install) + args = appendPackageAction(args, "remove", p.Remove) + args = appendPackageAction(args, "reinstall", p.Reinstall) + + if len(args) == 2 { + log.Println("no package changes to apply") + return nil + } + + cmd := exec.Command("dnf", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() + +} + +// Apply changes to the environment +func (e *EnvManifest) ApplyChanges() error { + return e.Packages.CommitTransaction() +} + +func (e *EnvManifest) BuildContainerfile() error { + // run podman build -t + podmanArgs := []string{ + "build", + "--pull=newer", + "-f", + UmEnvContainerfile, + "-t", + UmEnvManagedImage, + UmEnvContext, + } + cmd := exec.Command("podman", podmanArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return err + } + + return nil +} + +func EnvBootcSwitch() error { + args := []string{"switch", "--transport=containers-storage", UmEnvManagedImage} + cmd := exec.Command("bootc", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} diff --git a/env/util.go b/env/util.go new file mode 100644 index 0000000..382086c --- /dev/null +++ b/env/util.go @@ -0,0 +1,53 @@ +package env + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" + + "github.com/Ultramarine-Linux/um/pkg/util" + "go.yaml.in/yaml/v4" +) + +// minimal struct to parse `bootc edit` output +type bootcEditSpec struct { + Spec struct { + Image struct { + Image string `yaml:"image"` + } `yaml:"image"` + } `yaml:"spec"` +} + +func GetBootcImage() (string, error) { + util.SudoIfNeeded([]string{}) + + // run `bootc edit` and pipe output + // which then gets parsed by YAML + + bootcEditCmd := exec.Command("bootc", "edit") + bootcEditCmd.Env = append(os.Environ(), + "EDITOR=cat", + ) + + bootcEditOutput, err := bootcEditCmd.Output() + + if err != nil { + return "", err + } + + bootcEditOutput = bytes.TrimSpace(bootcEditOutput) + bootcEditOutput = []byte(strings.TrimSuffix(string(bootcEditOutput), "\nEdit cancelled, no changes made.")) + + var bootcEditParsed bootcEditSpec + if err := yaml.Unmarshal(bootcEditOutput, &bootcEditParsed); err != nil { + return "", err + } + + if bootcEditParsed.Spec.Image.Image == "" { + return "", errors.New("bootc edit output did not contain spec.image.image") + } + + return bootcEditParsed.Spec.Image.Image, nil +} diff --git a/go.mod b/go.mod index 3f6aad5..12a8813 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,11 @@ require ( github.com/mackerelio/go-osstat v0.2.5 github.com/samber/lo v1.49.1 github.com/urfave/cli/v2 v2.27.6 - go.yaml.in/yaml/v4 v4.0.0-rc.2 + go.yaml.in/yaml/v4 v4.0.0-rc.4 ) require ( + github.com/BurntSushi/toml v1.6.0 // indirect github.com/charmbracelet/colorprofile v0.3.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/go.sum b/go.sum index 42da2c2..bf53597 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= @@ -100,6 +102,8 @@ go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= +go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= diff --git a/main.go b/main.go index 73df016..54e0f7f 100644 --- a/main.go +++ b/main.go @@ -80,6 +80,43 @@ func runCli() error { }, }, + { + Name: "env", + Usage: "Manage local bootc derivations", + Subcommands: []*cli.Command{ + { + Name: "init", + Usage: "Initialize the bootc environment manifest and Containerfile", + Action: envInit, + }, + { + Name: "build", + Usage: "Build the local derivation from a Containerfile", + Action: envBuild, + }, + { + Name: "add", + Usage: "Add a package to the environment", + Action: envAddPackage, + }, + { + Name: "remove", + Usage: "Remove a package from the environment", + Action: envRemovePackage, + }, + { + Name: "apply-changes", + Usage: "Apply pending changes to the bootc environment", + Action: envApplyChanges, + }, + { + Name: "update", + Usage: "Update the base image and rebuild the environment", + Action: envUpdate, + }, + }, + }, + // { // Name: "experiments", // Usage: "manage Ultramarine Linux experiments, a preview of features to come",