diff options
| author | Ophestra <cat@gensokyo.uk> | 2025-07-31 23:57:11 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2025-07-31 23:57:11 +0900 |
| commit | a8a79a866469c281f5c15daa7b2bd1de91f1d99c (patch) | |
| tree | a43ec55565b9207b813528ac21da6c07ad2f5bc3 /cmd/hpkg | |
| parent | 3ae0cec000408562452703b2c0faad42260e3b53 (diff) | |
cmd/hpkg: rename from planterette
Planterette is now developed in another repository, so rename this proof of concept to avoid confusion.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'cmd/hpkg')
| -rw-r--r-- | cmd/hpkg/app.go | 154 | ||||
| -rw-r--r-- | cmd/hpkg/build.nix | 251 | ||||
| -rw-r--r-- | cmd/hpkg/main.go | 333 | ||||
| -rw-r--r-- | cmd/hpkg/paths.go | 101 | ||||
| -rw-r--r-- | cmd/hpkg/proc.go | 60 | ||||
| -rw-r--r-- | cmd/hpkg/test/configuration.nix | 62 | ||||
| -rw-r--r-- | cmd/hpkg/test/default.nix | 34 | ||||
| -rw-r--r-- | cmd/hpkg/test/foot.nix | 48 | ||||
| -rw-r--r-- | cmd/hpkg/test/test.py | 108 | ||||
| -rw-r--r-- | cmd/hpkg/with.go | 114 |
10 files changed, 1265 insertions, 0 deletions
diff --git a/cmd/hpkg/app.go b/cmd/hpkg/app.go new file mode 100644 index 00000000..082bc28e --- /dev/null +++ b/cmd/hpkg/app.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "path" + + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/dbus" +) + +type appInfo struct { + Name string `json:"name"` + Version string `json:"version"` + + // passed through to [hst.Config] + ID string `json:"id"` + // passed through to [hst.Config] + Identity int `json:"identity"` + // passed through to [hst.Config] + Groups []string `json:"groups,omitempty"` + // passed through to [hst.Config] + Devel bool `json:"devel,omitempty"` + // passed through to [hst.Config] + Userns bool `json:"userns,omitempty"` + // passed through to [hst.Config] + Net bool `json:"net,omitempty"` + // passed through to [hst.Config] + Device bool `json:"dev,omitempty"` + // passed through to [hst.Config] + Tty bool `json:"tty,omitempty"` + // passed through to [hst.Config] + MapRealUID bool `json:"map_real_uid,omitempty"` + // passed through to [hst.Config] + DirectWayland bool `json:"direct_wayland,omitempty"` + // passed through to [hst.Config] + SystemBus *dbus.Config `json:"system_bus,omitempty"` + // passed through to [hst.Config] + SessionBus *dbus.Config `json:"session_bus,omitempty"` + // passed through to [hst.Config] + Enablements system.Enablement `json:"enablements"` + + // passed through to [hst.Config] + Multiarch bool `json:"multiarch,omitempty"` + // passed through to [hst.Config] + Bluetooth bool `json:"bluetooth,omitempty"` + + // allow gpu access within sandbox + GPU bool `json:"gpu"` + // store path to nixGL mesa wrappers + Mesa string `json:"mesa,omitempty"` + // store path to nixGL source + NixGL string `json:"nix_gl,omitempty"` + // store path to activate-and-exec script + Launcher string `json:"launcher"` + // store path to /run/current-system + CurrentSystem string `json:"current_system"` + // store path to home-manager activation package + ActivationPackage string `json:"activation_package"` +} + +func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *hst.Config { + config := &hst.Config{ + ID: app.ID, + + Path: argv[0], + Args: argv, + + Enablements: app.Enablements, + + SystemBus: app.SystemBus, + SessionBus: app.SessionBus, + DirectWayland: app.DirectWayland, + + Username: "hakurei", + Shell: shellPath, + Data: pathSet.homeDir, + Dir: path.Join("/data/data", app.ID), + + Identity: app.Identity, + Groups: app.Groups, + + Container: &hst.ContainerConfig{ + Hostname: formatHostname(app.Name), + Devel: app.Devel, + Userns: app.Userns, + Net: app.Net, + Device: app.Device, + Tty: app.Tty || flagDropShell, + MapRealUID: app.MapRealUID, + Filesystem: []*hst.FilesystemConfig{ + {Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true}, + {Src: pathSet.metaPath, Dst: path.Join(hst.Tmp, "app"), Must: true}, + {Src: "/etc/resolv.conf"}, + {Src: "/sys/block"}, + {Src: "/sys/bus"}, + {Src: "/sys/class"}, + {Src: "/sys/dev"}, + {Src: "/sys/devices"}, + }, + Link: [][2]string{ + {app.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(pathSet.cacheDir, "etc"), + AutoEtc: true, + }, + ExtraPerms: []*hst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + }, + } + if app.Multiarch { + config.Container.SeccompFlags |= seccomp.AllowMultiarch + } + if app.Bluetooth { + config.Container.SeccompFlags |= seccomp.AllowBluetooth + } + return config +} + +func loadAppInfo(name string, beforeFail func()) *appInfo { + bundle := new(appInfo) + if f, err := os.Open(name); err != nil { + beforeFail() + log.Fatalf("cannot open bundle: %v", err) + } else if err = json.NewDecoder(f).Decode(&bundle); err != nil { + beforeFail() + log.Fatalf("cannot parse bundle metadata: %v", err) + } else if err = f.Close(); err != nil { + log.Printf("cannot close bundle metadata: %v", err) + // not fatal + } + + if bundle.ID == "" { + beforeFail() + log.Fatal("application identifier must not be empty") + } + + return bundle +} + +func formatHostname(name string) string { + if h, err := os.Hostname(); err != nil { + log.Printf("cannot get hostname: %v", err) + return "hakurei-" + name + } else { + return h + "-" + name + } +} diff --git a/cmd/hpkg/build.nix b/cmd/hpkg/build.nix new file mode 100644 index 00000000..13f3144f --- /dev/null +++ b/cmd/hpkg/build.nix @@ -0,0 +1,251 @@ +{ + nixpkgsFor, + system, + nixpkgs, + home-manager, +}: + +{ + lib, + stdenv, + closureInfo, + writeScript, + runtimeShell, + writeText, + symlinkJoin, + vmTools, + runCommand, + fetchFromGitHub, + + zstd, + nix, + sqlite, + + name ? throw "name is required", + version ? throw "version is required", + pname ? "${name}-${version}", + modules ? [ ], + nixosModules ? [ ], + script ? '' + exec "$SHELL" "$@" + '', + + id ? name, + identity ? throw "identity is required", + groups ? [ ], + userns ? false, + net ? true, + dev ? false, + no_new_session ? false, + map_real_uid ? false, + direct_wayland ? false, + system_bus ? null, + session_bus ? null, + + allow_wayland ? true, + allow_x11 ? false, + allow_dbus ? true, + allow_pulse ? true, + gpu ? allow_wayland || allow_x11, +}: + +let + inherit (lib) optionals; + + homeManagerConfiguration = home-manager.lib.homeManagerConfiguration { + pkgs = nixpkgsFor.${system}; + modules = modules ++ [ + { + home = { + username = "hakurei"; + homeDirectory = "/data/data/${id}"; + stateVersion = "22.11"; + }; + } + ]; + }; + + launcher = writeScript "hakurei-${pname}" '' + #!${runtimeShell} -el + ${script} + ''; + + extraNixOSConfig = + { pkgs, ... }: + { + environment = { + etc.nixpkgs.source = nixpkgs.outPath; + systemPackages = [ pkgs.nix ]; + }; + + imports = nixosModules; + }; + nixos = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + extraNixOSConfig + { nix.settings.experimental-features = [ "flakes" ]; } + { nix.settings.experimental-features = [ "nix-command" ]; } + { boot.isContainer = true; } + { system.stateVersion = "22.11"; } + ]; + }; + + etc = vmTools.runInLinuxVM ( + runCommand "etc" { } '' + mkdir -p /etc + ${nixos.config.system.build.etcActivationCommands} + + # remove unused files + rm -rf /etc/sudoers + + mkdir -p $out + tar -C /etc -cf "$out/etc.tar" . + '' + ); + + extendSessionDefault = id: ext: { + filter = true; + + talk = [ "org.freedesktop.Notifications" ] ++ ext.talk; + own = + (optionals (id != null) [ + "${id}.*" + "org.mpris.MediaPlayer2.${id}.*" + ]) + ++ ext.own; + + inherit (ext) call broadcast; + }; + + nixGL = fetchFromGitHub { + owner = "nix-community"; + repo = "nixGL"; + rev = "310f8e49a149e4c9ea52f1adf70cdc768ec53f8a"; + hash = "sha256-lnzZQYG0+EXl/6NkGpyIz+FEOc/DSEG57AP1VsdeNrM="; + }; + + mesaWrappers = + let + isIntelX86Platform = system == "x86_64-linux"; + nixGLPackages = import (nixGL + "/default.nix") { + pkgs = nixpkgs.legacyPackages.${system}; + enable32bits = isIntelX86Platform; + enableIntelX86Extensions = isIntelX86Platform; + }; + in + symlinkJoin { + name = "nixGL-mesa"; + paths = with nixGLPackages; [ + nixGLIntel + nixVulkanIntel + ]; + }; + + info = builtins.toJSON { + inherit + name + version + id + identity + launcher + groups + userns + net + dev + no_new_session + map_real_uid + direct_wayland + system_bus + gpu + ; + + session_bus = + if session_bus != null then + (session_bus (extendSessionDefault id)) + else + (extendSessionDefault id { + talk = [ ]; + own = [ ]; + call = { }; + broadcast = { }; + }); + + enablements = (if allow_wayland then 1 else 0) + (if allow_x11 then 2 else 0) + (if allow_dbus then 4 else 0) + (if allow_pulse then 8 else 0); + + mesa = if gpu then mesaWrappers else null; + nix_gl = if gpu then nixGL else null; + current_system = nixos.config.system.build.toplevel; + activation_package = homeManagerConfiguration.activationPackage; + }; +in + +stdenv.mkDerivation { + name = "${pname}.pkg"; + inherit version; + __structuredAttrs = true; + + nativeBuildInputs = [ + zstd + nix + sqlite + ]; + + buildCommand = '' + NIX_ROOT="$(mktemp -d)" + export USER="nobody" + + # create bootstrap store + bootstrapClosureInfo="${ + closureInfo { + rootPaths = [ + nix + nixos.config.system.build.toplevel + ]; + } + }" + echo "copying bootstrap store paths..." + mkdir -p "$NIX_ROOT/nix/store" + xargs -n 1 -a "$bootstrapClosureInfo/store-paths" cp -at "$NIX_ROOT/nix/store/" + NIX_REMOTE="local?root=$NIX_ROOT" nix-store --load-db < "$bootstrapClosureInfo/registration" + NIX_REMOTE="local?root=$NIX_ROOT" nix-store --optimise + sqlite3 "$NIX_ROOT/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}" + chmod -R +r "$NIX_ROOT/nix/var" + + # create binary cache + closureInfo="${ + closureInfo { + rootPaths = [ + homeManagerConfiguration.activationPackage + launcher + ] + ++ optionals gpu [ + mesaWrappers + nixGL + ]; + } + }" + echo "copying application paths..." + TMP_STORE="$(mktemp -d)" + mkdir -p "$TMP_STORE/nix/store" + xargs -n 1 -a "$closureInfo/store-paths" cp -at "$TMP_STORE/nix/store/" + NIX_REMOTE="local?root=$TMP_STORE" nix-store --load-db < "$closureInfo/registration" + sqlite3 "$TMP_STORE/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}" + NIX_REMOTE="local?root=$TMP_STORE" nix --offline --extra-experimental-features nix-command \ + --verbose --log-format raw-with-logs \ + copy --all --no-check-sigs --to \ + "file://$NIX_ROOT/res?compression=zstd&compression-level=19¶llel-compression=true" + + # package /etc + mkdir -p "$NIX_ROOT/etc" + tar -C "$NIX_ROOT/etc" -xf "${etc}/etc.tar" + + # write metadata + cp "${writeText "bundle.json" info}" "$NIX_ROOT/bundle.json" + + # create an intermediate file to improve zstd performance + INTER="$(mktemp)" + tar -C "$NIX_ROOT" -cf "$INTER" . + zstd -T0 -19 -fo "$out" "$INTER" + ''; +} diff --git a/cmd/hpkg/main.go b/cmd/hpkg/main.go new file mode 100644 index 00000000..d808aa01 --- /dev/null +++ b/cmd/hpkg/main.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "os" + "os/signal" + "path" + "syscall" + + "hakurei.app/command" + "hakurei.app/hst" + "hakurei.app/internal" + "hakurei.app/internal/hlog" +) + +const shellPath = "/run/current-system/sw/bin/bash" + +var ( + errSuccess = errors.New("success") +) + +func init() { + hlog.Prepare("hpkg") + if err := os.Setenv("SHELL", shellPath); err != nil { + log.Fatalf("cannot set $SHELL: %v", err) + } +} + +func main() { + if os.Geteuid() == 0 { + log.Fatal("this program must not run as root") + } + + ctx, stop := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer stop() // unreachable + + var ( + flagVerbose bool + flagDropShell bool + ) + c := command.New(os.Stderr, log.Printf, "hpkg", func([]string) error { internal.InstallOutput(flagVerbose); return nil }). + Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console"). + Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next hakurei action") + + { + var ( + flagDropShellActivate bool + ) + c.NewCommand("install", "Install an application from its package", func(args []string) error { + if len(args) != 1 { + log.Println("invalid argument") + return syscall.EINVAL + } + pkgPath := args[0] + if !path.IsAbs(pkgPath) { + if dir, err := os.Getwd(); err != nil { + log.Printf("cannot get current directory: %v", err) + return err + } else { + pkgPath = path.Join(dir, pkgPath) + } + } + + /* + Look up paths to programs started by hpkg. + This is done here to ease error handling as cleanup is not yet required. + */ + + var ( + _ = lookPath("zstd") + tar = lookPath("tar") + chmod = lookPath("chmod") + rm = lookPath("rm") + ) + + /* + Extract package and set up for cleanup. + */ + + var workDir string + if p, err := os.MkdirTemp("", "hpkg.*"); err != nil { + log.Printf("cannot create temporary directory: %v", err) + return err + } else { + workDir = p + } + cleanup := func() { + // should be faster than a native implementation + mustRun(chmod, "-R", "+w", workDir) + mustRun(rm, "-rf", workDir) + } + beforeRunFail.Store(&cleanup) + + mustRun(tar, "-C", workDir, "-xf", pkgPath) + + /* + Parse bundle and app metadata, do pre-install checks. + */ + + bundle := loadAppInfo(path.Join(workDir, "bundle.json"), cleanup) + pathSet := pathSetByApp(bundle.ID) + + a := bundle + if s, err := os.Stat(pathSet.metaPath); err != nil { + if !os.IsNotExist(err) { + cleanup() + log.Printf("cannot access %q: %v", pathSet.metaPath, err) + return err + } + // did not modify app, clean installation condition met later + } else if s.IsDir() { + cleanup() + log.Printf("metadata path %q is not a file", pathSet.metaPath) + return syscall.EBADMSG + } else { + a = loadAppInfo(pathSet.metaPath, cleanup) + if a.ID != bundle.ID { + cleanup() + log.Printf("app %q claims to have identifier %q", + bundle.ID, a.ID) + return syscall.EBADE + } + // sec: should verify credentials + } + + if a != bundle { + // do not try to re-install + if a.NixGL == bundle.NixGL && + a.CurrentSystem == bundle.CurrentSystem && + a.Launcher == bundle.Launcher && + a.ActivationPackage == bundle.ActivationPackage { + cleanup() + log.Printf("package %q is identical to local application %q", + pkgPath, a.ID) + return errSuccess + } + + // identity determines uid + if a.Identity != bundle.Identity { + cleanup() + log.Printf("package %q identity %d differs from installed %d", + pkgPath, bundle.Identity, a.Identity) + return syscall.EBADE + } + + // sec: should compare version string + hlog.Verbosef("installing application %q version %q over local %q", + bundle.ID, bundle.Version, a.Version) + } else { + hlog.Verbosef("application %q clean installation", bundle.ID) + // sec: should install credentials + } + + /* + Setup steps for files owned by the target user. + */ + + withCacheDir(ctx, "install", []string{ + // export inner bundle path in the environment + "export BUNDLE=" + hst.Tmp + "/bundle", + // replace inner /etc + "mkdir -p etc", + "chmod -R +w etc", + "rm -rf etc", + "cp -dRf $BUNDLE/etc etc", + // replace inner /nix + "mkdir -p nix", + "chmod -R +w nix", + "rm -rf nix", + "cp -dRf /nix nix", + // copy from binary cache + "nix copy --offline --no-check-sigs --all --from file://$BUNDLE/res --to $PWD", + // deduplicate nix store + "nix store --offline --store $PWD optimise", + // make cache directory world-readable for autoetc + "chmod 0755 .", + }, workDir, bundle, pathSet, flagDropShell, cleanup) + + if bundle.GPU { + withCacheDir(ctx, "mesa-wrappers", []string{ + // link nixGL mesa wrappers + "mkdir -p nix/.nixGL", + "ln -s " + bundle.Mesa + "/bin/nixGLIntel nix/.nixGL/nixGL", + "ln -s " + bundle.Mesa + "/bin/nixVulkanIntel nix/.nixGL/nixVulkan", + }, workDir, bundle, pathSet, false, cleanup) + } + + /* + Activate home-manager generation. + */ + + withNixDaemon(ctx, "activate", []string{ + // clean up broken links + "mkdir -p .local/state/{nix,home-manager}", + "chmod -R +w .local/state/{nix,home-manager}", + "rm -rf .local/state/{nix,home-manager}", + // run activation script + bundle.ActivationPackage + "/activate", + }, false, func(config *hst.Config) *hst.Config { return config }, + bundle, pathSet, flagDropShellActivate, cleanup) + + /* + Installation complete. Write metadata to block re-installs or downgrades. + */ + + // serialise metadata to ensure consistency + if f, err := os.OpenFile(pathSet.metaPath+"~", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err != nil { + cleanup() + log.Printf("cannot create metadata file: %v", err) + return err + } else if err = json.NewEncoder(f).Encode(bundle); err != nil { + cleanup() + log.Printf("cannot write metadata: %v", err) + return err + } else if err = f.Close(); err != nil { + log.Printf("cannot close metadata file: %v", err) + // not fatal + } + + if err := os.Rename(pathSet.metaPath+"~", pathSet.metaPath); err != nil { + cleanup() + log.Printf("cannot rename metadata file: %v", err) + return err + } + + cleanup() + return errSuccess + }). + Flag(&flagDropShellActivate, "s", command.BoolFlag(false), "Drop to a shell on activation") + } + + { + var ( + flagDropShellNixGL bool + flagAutoDrivers bool + ) + c.NewCommand("start", "Start an application", func(args []string) error { + if len(args) < 1 { + log.Println("invalid argument") + return syscall.EINVAL + } + + /* + Parse app metadata. + */ + + id := args[0] + pathSet := pathSetByApp(id) + a := loadAppInfo(pathSet.metaPath, func() {}) + if a.ID != id { + log.Printf("app %q claims to have identifier %q", id, a.ID) + return syscall.EBADE + } + + /* + Prepare nixGL. + */ + + if a.GPU && flagAutoDrivers { + withNixDaemon(ctx, "nix-gl", []string{ + "mkdir -p /nix/.nixGL/auto", + "rm -rf /nix/.nixGL/auto", + "export NIXPKGS_ALLOW_UNFREE=1", + "nix build --impure " + + "--out-link /nix/.nixGL/auto/opengl " + + "--override-input nixpkgs path:/etc/nixpkgs " + + "path:" + a.NixGL, + "nix build --impure " + + "--out-link /nix/.nixGL/auto/vulkan " + + "--override-input nixpkgs path:/etc/nixpkgs " + + "path:" + a.NixGL + "#nixVulkanNvidia", + }, true, func(config *hst.Config) *hst.Config { + config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ + {Src: "/etc/resolv.conf"}, + {Src: "/sys/block"}, + {Src: "/sys/bus"}, + {Src: "/sys/class"}, + {Src: "/sys/dev"}, + {Src: "/sys/devices"}, + }...) + appendGPUFilesystem(config) + return config + }, a, pathSet, flagDropShellNixGL, func() {}) + } + + /* + Create app configuration. + */ + + argv := make([]string, 1, len(args)) + if !flagDropShell { + argv[0] = a.Launcher + } else { + argv[0] = shellPath + } + argv = append(argv, args[1:]...) + + config := a.toFst(pathSet, argv, flagDropShell) + + /* + Expose GPU devices. + */ + + if a.GPU { + config.Container.Filesystem = append(config.Container.Filesystem, + &hst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(hst.Tmp, "nixGL")}) + appendGPUFilesystem(config) + } + + /* + Spawn app. + */ + + mustRunApp(ctx, config, func() {}) + return errSuccess + }). + Flag(&flagDropShellNixGL, "s", command.BoolFlag(false), "Drop to a shell on nixGL build"). + Flag(&flagAutoDrivers, "auto-drivers", command.BoolFlag(false), "Attempt automatic opengl driver detection") + } + + c.MustParse(os.Args[1:], func(err error) { + hlog.Verbosef("command returned %v", err) + if errors.Is(err, errSuccess) { + hlog.BeforeExit() + os.Exit(0) + } + }) + log.Fatal("unreachable") +} diff --git a/cmd/hpkg/paths.go b/cmd/hpkg/paths.go new file mode 100644 index 00000000..11715fb2 --- /dev/null +++ b/cmd/hpkg/paths.go @@ -0,0 +1,101 @@ +package main + +import ( + "log" + "os" + "os/exec" + "path" + "strconv" + "sync/atomic" + + "hakurei.app/hst" + "hakurei.app/internal/hlog" +) + +var ( + dataHome string +) + +func init() { + // dataHome + if p, ok := os.LookupEnv("HAKUREI_DATA_HOME"); ok { + dataHome = p + } else { + dataHome = "/var/lib/hakurei/" + strconv.Itoa(os.Getuid()) + } +} + +func lookPath(file string) string { + if p, err := exec.LookPath(file); err != nil { + log.Fatalf("%s: command not found", file) + return "" + } else { + return p + } +} + +var beforeRunFail = new(atomic.Pointer[func()]) + +func mustRun(name string, arg ...string) { + hlog.Verbosef("spawning process: %q %q", name, arg) + cmd := exec.Command(name, arg...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + if f := beforeRunFail.Swap(nil); f != nil { + (*f)() + } + log.Fatalf("%s: %v", name, err) + } +} + +type appPathSet struct { + // ${dataHome}/${id} + baseDir string + // ${baseDir}/app + metaPath string + // ${baseDir}/files + homeDir string + // ${baseDir}/cache + cacheDir string + // ${baseDir}/cache/nix + nixPath string +} + +func pathSetByApp(id string) *appPathSet { + pathSet := new(appPathSet) + pathSet.baseDir = path.Join(dataHome, id) + pathSet.metaPath = path.Join(pathSet.baseDir, "app") + pathSet.homeDir = path.Join(pathSet.baseDir, "files") + pathSet.cacheDir = path.Join(pathSet.baseDir, "cache") + pathSet.nixPath = path.Join(pathSet.cacheDir, "nix") + return pathSet +} + +func appendGPUFilesystem(config *hst.Config) { + config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ + // flatpak commit 763a686d874dd668f0236f911de00b80766ffe79 + {Src: "/dev/dri", Device: true}, + // mali + {Src: "/dev/mali", Device: true}, + {Src: "/dev/mali0", Device: true}, + {Src: "/dev/umplock", Device: true}, + // nvidia + {Src: "/dev/nvidiactl", Device: true}, + {Src: "/dev/nvidia-modeset", Device: true}, + // nvidia OpenCL/CUDA + {Src: "/dev/nvidia-uvm", Device: true}, + {Src: "/dev/nvidia-uvm-tools", Device: true}, + + // flatpak commit d2dff2875bb3b7e2cd92d8204088d743fd07f3ff + {Src: "/dev/nvidia0", Device: true}, {Src: "/dev/nvidia1", Device: true}, + {Src: "/dev/nvidia2", Device: true}, {Src: "/dev/nvidia3", Device: true}, + {Src: "/dev/nvidia4", Device: true}, {Src: "/dev/nvidia5", Device: true}, + {Src: "/dev/nvidia6", Device: true}, {Src: "/dev/nvidia7", Device: true}, + {Src: "/dev/nvidia8", Device: true}, {Src: "/dev/nvidia9", Device: true}, + {Src: "/dev/nvidia10", Device: true}, {Src: "/dev/nvidia11", Device: true}, + {Src: "/dev/nvidia12", Device: true}, {Src: "/dev/nvidia13", Device: true}, + {Src: "/dev/nvidia14", Device: true}, {Src: "/dev/nvidia15", Device: true}, + {Src: "/dev/nvidia16", Device: true}, {Src: "/dev/nvidia17", Device: true}, + {Src: "/dev/nvidia18", Device: true}, {Src: "/dev/nvidia19", Device: true}, + }...) +} diff --git a/cmd/hpkg/proc.go b/cmd/hpkg/proc.go new file mode 100644 index 00000000..d374e13d --- /dev/null +++ b/cmd/hpkg/proc.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "log" + "os" + "os/exec" + + "hakurei.app/hst" + "hakurei.app/internal" + "hakurei.app/internal/hlog" +) + +var hakureiPath = internal.MustHakureiPath() + +func mustRunApp(ctx context.Context, config *hst.Config, beforeFail func()) { + var ( + cmd *exec.Cmd + st io.WriteCloser + ) + + if r, w, err := os.Pipe(); err != nil { + beforeFail() + log.Fatalf("cannot pipe: %v", err) + } else { + if hlog.Load() { + cmd = exec.CommandContext(ctx, hakureiPath, "-v", "app", "3") + } else { + cmd = exec.CommandContext(ctx, hakureiPath, "app", "3") + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.ExtraFiles = []*os.File{r} + st = w + } + + go func() { + if err := json.NewEncoder(st).Encode(config); err != nil { + beforeFail() + log.Fatalf("cannot send configuration: %v", err) + } + }() + + if err := cmd.Start(); err != nil { + beforeFail() + log.Fatalf("cannot start hakurei: %v", err) + } + if err := cmd.Wait(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + beforeFail() + internal.Exit(exitError.ExitCode()) + } else { + beforeFail() + log.Fatalf("cannot wait: %v", err) + } + } +} diff --git a/cmd/hpkg/test/configuration.nix b/cmd/hpkg/test/configuration.nix new file mode 100644 index 00000000..ce74228a --- /dev/null +++ b/cmd/hpkg/test/configuration.nix @@ -0,0 +1,62 @@ +{ pkgs, ... }: +{ + users.users = { + alice = { + isNormalUser = true; + description = "Alice Foobar"; + password = "foobar"; + uid = 1000; + }; + }; + + home-manager.users.alice.home.stateVersion = "24.11"; + + # Automatically login on tty1 as a normal user: + services.getty.autologinUser = "alice"; + + environment = { + variables = { + SWAYSOCK = "/tmp/sway-ipc.sock"; + WLR_RENDERER = "pixman"; + }; + }; + + # Automatically configure and start Sway when logging in on tty1: + programs.bash.loginShellInit = '' + if [ "$(tty)" = "/dev/tty1" ]; then + set -e + + mkdir -p ~/.config/sway + (sed s/Mod4/Mod1/ /etc/sway/config && + echo 'output * bg ${pkgs.nixos-artwork.wallpapers.simple-light-gray.gnomeFilePath} fill' && + echo 'output Virtual-1 res 1680x1050') > ~/.config/sway/config + + sway --validate + systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok + fi + ''; + + programs.sway.enable = true; + + virtualisation = { + diskSize = 6 * 1024; + + qemu.options = [ + # Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch: + "-vga none -device virtio-gpu-pci" + + # Increase zstd performance: + "-smp 8" + ]; + }; + + environment.hakurei = { + enable = true; + stateDir = "/var/lib/hakurei"; + users.alice = 0; + + extraHomeConfig = { + home.stateVersion = "23.05"; + }; + }; +} diff --git a/cmd/hpkg/test/default.nix b/cmd/hpkg/test/default.nix new file mode 100644 index 00000000..4d5e3b02 --- /dev/null +++ b/cmd/hpkg/test/default.nix @@ -0,0 +1,34 @@ +{ + nixosTest, + callPackage, + + system, + self, +}: +let + buildPackage = self.buildPackage.${system}; +in +nixosTest { + name = "hpkg"; + nodes.machine = { + environment.etc = { + "foot.pkg".source = callPackage ./foot.nix { inherit buildPackage; }; + }; + + imports = [ + ./configuration.nix + + self.nixosModules.hakurei + self.inputs.home-manager.nixosModules.home-manager + ]; + }; + + # adapted from nixos sway integration tests + + # testScriptWithTypes:49: error: Cannot call function of unknown type + # (machine.succeed if succeed else machine.execute)( + # ^ + # Found 1 error in 1 file (checked 1 source file) + skipTypeCheck = true; + testScript = builtins.readFile ./test.py; +} diff --git a/cmd/hpkg/test/foot.nix b/cmd/hpkg/test/foot.nix new file mode 100644 index 00000000..76b677ac --- /dev/null +++ b/cmd/hpkg/test/foot.nix @@ -0,0 +1,48 @@ +{ + lib, + buildPackage, + foot, + wayland-utils, + inconsolata, +}: + +buildPackage { + name = "foot"; + inherit (foot) version; + + identity = 2; + id = "org.codeberg.dnkl.foot"; + + modules = [ + { + home.packages = [ + foot + + # For wayland-info: + wayland-utils + ]; + } + ]; + + nixosModules = [ + { + # To help with OCR: + environment.etc."xdg/foot/foot.ini".text = lib.generators.toINI { } { + main = { + font = "inconsolata:size=14"; + }; + colors = rec { + foreground = "000000"; + background = "ffffff"; + regular2 = foreground; + }; + }; + + fonts.packages = [ inconsolata ]; + } + ]; + + script = '' + exec foot "$@" + ''; +} diff --git a/cmd/hpkg/test/test.py b/cmd/hpkg/test/test.py new file mode 100644 index 00000000..c9944354 --- /dev/null +++ b/cmd/hpkg/test/test.py @@ -0,0 +1,108 @@ +import json +import shlex + +q = shlex.quote +NODE_GROUPS = ["nodes", "floating_nodes"] + + +def swaymsg(command: str = "", succeed=True, type="command"): + assert command != "" or type != "command", "Must specify command or type" + shell = q(f"swaymsg -t {q(type)} -- {q(command)}") + with machine.nested( + f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed) + ): + ret = (machine.succeed if succeed else machine.execute)( + f"su - alice -c {shell}" + ) + + # execute also returns a status code, but disregard. + if not succeed: + _, ret = ret + + if not succeed and not ret: + return None + + parsed = json.loads(ret) + return parsed + + +def walk(tree): + yield tree + for group in NODE_GROUPS: + for node in tree.get(group, []): + yield from walk(node) + + +def wait_for_window(pattern): + def func(last_chance): + nodes = (node["name"] for node in walk(swaymsg(type="get_tree"))) + + if last_chance: + nodes = list(nodes) + machine.log(f"Last call! Current list of windows: {nodes}") + + return any(pattern in name for name in nodes) + + retry(func) + + +def collect_state_ui(name): + swaymsg(f"exec hakurei ps > '/tmp/{name}.ps'") + machine.copy_from_vm(f"/tmp/{name}.ps", "") + swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'") + machine.copy_from_vm(f"/tmp/{name}.json", "") + machine.screenshot(name) + + +def check_state(name, enablements): + instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei --json ps")) + if len(instances) != 1: + raise Exception(f"unexpected state length {len(instances)}") + instance = next(iter(instances.values())) + + config = instance['config'] + + if len(config['args']) != 1 or not (config['args'][0].startswith("/nix/store/")) or f"hakurei-{name}-" not in (config['args'][0]): + raise Exception(f"unexpected args {instance['config']['args']}") + + if config['enablements'] != enablements: + raise Exception(f"unexpected enablements {instance['config']['enablements']}") + + +start_all() +machine.wait_for_unit("multi-user.target") + +# To check hakurei's version: +print(machine.succeed("sudo -u alice -i hakurei version")) + +# Wait for Sway to complete startup: +machine.wait_for_file("/run/user/1000/wayland-1") +machine.wait_for_file("/tmp/sway-ipc.sock") + +# Prepare hpkg directory: +machine.succeed("install -dm 0700 -o alice -g users /var/lib/hakurei/1000") + +# Install hpkg app: +swaymsg("exec hpkg -v install /etc/foot.pkg && touch /tmp/hpkg-install-ok") +machine.wait_for_file("/tmp/hpkg-install-ok") + +# Start app (foot) with Wayland enablement: +swaymsg("exec hpkg -v start org.codeberg.dnkl.foot") +wait_for_window("hakurei@machine-foot") +machine.send_chars("clear; wayland-info && touch /tmp/success-client\n") +machine.wait_for_file("/tmp/hakurei.1000/tmpdir/2/success-client") +collect_state_ui("app_wayland") +check_state("foot", 13) +# Verify acl on XDG_RUNTIME_DIR: +print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002")) +machine.send_chars("exit\n") +machine.wait_until_fails("pgrep foot") +# Verify acl cleanup on XDG_RUNTIME_DIR: +machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002") + +# Exit Sway and verify process exit status 0: +swaymsg("exit", succeed=False) +machine.wait_for_file("/tmp/sway-exit-ok") + +# Print hakurei runDir contents: +print(machine.succeed("find /run/user/1000/hakurei"))
\ No newline at end of file diff --git a/cmd/hpkg/with.go b/cmd/hpkg/with.go new file mode 100644 index 00000000..7d9fc7b8 --- /dev/null +++ b/cmd/hpkg/with.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "path" + "strings" + + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/internal" +) + +func withNixDaemon( + ctx context.Context, + action string, command []string, net bool, updateConfig func(config *hst.Config) *hst.Config, + app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(), +) { + mustRunAppDropShell(ctx, updateConfig(&hst.Config{ + ID: app.ID, + + Path: shellPath, + Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " + + // start nix-daemon + "nix-daemon --store / & " + + // wait for socket to appear + "(while [ ! -S /nix/var/nix/daemon-socket/socket ]; do sleep 0.01; done) && " + + // create directory so nix stops complaining + "mkdir -p /nix/var/nix/profiles/per-user/root/channels && " + + strings.Join(command, " && ") + + // terminate nix-daemon + " && pkill nix-daemon", + }, + + Username: "hakurei", + Shell: shellPath, + Data: pathSet.homeDir, + Dir: path.Join("/data/data", app.ID), + ExtraPerms: []*hst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + }, + + Identity: app.Identity, + + Container: &hst.ContainerConfig{ + Hostname: formatHostname(app.Name) + "-" + action, + Userns: true, // nix sandbox requires userns + Net: net, + SeccompFlags: seccomp.AllowMultiarch, + Tty: dropShell, + Filesystem: []*hst.FilesystemConfig{ + {Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true}, + }, + Link: [][2]string{ + {app.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(pathSet.cacheDir, "etc"), + AutoEtc: true, + }, + }), dropShell, beforeFail) +} + +func withCacheDir( + ctx context.Context, + action string, command []string, workDir string, + app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) { + mustRunAppDropShell(ctx, &hst.Config{ + ID: app.ID, + + Path: shellPath, + Args: []string{shellPath, "-lc", strings.Join(command, " && ")}, + + Username: "nixos", + Shell: shellPath, + Data: pathSet.cacheDir, // this also ensures cacheDir via shim + Dir: path.Join("/data/data", app.ID, "cache"), + ExtraPerms: []*hst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + {Path: workDir, Execute: true}, + }, + + Identity: app.Identity, + + Container: &hst.ContainerConfig{ + Hostname: formatHostname(app.Name) + "-" + action, + SeccompFlags: seccomp.AllowMultiarch, + Tty: dropShell, + Filesystem: []*hst.FilesystemConfig{ + {Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true}, + {Src: workDir, Dst: path.Join(hst.Tmp, "bundle"), Must: true}, + }, + Link: [][2]string{ + {app.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(workDir, "etc"), + AutoEtc: true, + }, + }, dropShell, beforeFail) +} + +func mustRunAppDropShell(ctx context.Context, config *hst.Config, dropShell bool, beforeFail func()) { + if dropShell { + config.Args = []string{shellPath, "-l"} + mustRunApp(ctx, config, beforeFail) + beforeFail() + internal.Exit(0) + } + mustRunApp(ctx, config, beforeFail) +} |
