package install import ( "cmp" "context" "crypto/rand" "crypto/sha256" "embed" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "regexp" "slices" "sort" "strconv" "strings" "sync" "time" "github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1" "github.com/johnandersen777/socialweb-computer-kcp/internal/bootstrap" "github.com/johnandersen777/socialweb-computer-kcp/internal/bootstrap/table" "github.com/johnandersen777/socialweb-computer-kcp/internal/buildstamp" "github.com/johnandersen777/socialweb-computer-kcp/internal/devinstance" "github.com/johnandersen777/socialweb-computer-kcp/internal/guestimage" "github.com/johnandersen777/socialweb-computer-kcp/internal/guestimagerelease" "github.com/johnandersen777/socialweb-computer-kcp/internal/hostcontract" "github.com/johnandersen777/socialweb-computer-kcp/internal/hostsvc" "github.com/johnandersen777/socialweb-computer-kcp/internal/kcpserver" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/fsx" "github.com/johnandersen777/socialweb-computer-kcp/internal/kit/sha256sum" "github.com/johnandersen777/socialweb-computer-kcp/internal/progress" "github.com/johnandersen777/socialweb-computer-kcp/internal/vmruntime" ) //go:embed templates var templates embed.FS const ( AccountGateway = "socialweb" AccountBidder = "swc-bidder" AccountRequester = "swc-requester" BidderProfile = "prod-qr" StateRoot = "/var/lib/socialweb-computer" DefaultVMSlots = 256 SubidFloor = 100000 KVMGroup = "kvm" CoreName = "socialweb-computer" GatewayUnit = hostsvc.NamePrefix + table.GatewayCore BidderTemplate = hostsvc.NamePrefix + table.BidderCore + hostsvc.ServiceSuffix RequesterTemplate = hostsvc.NamePrefix + table.RequesterCore + hostsvc.ServiceSuffix SliceUnit = "socialweb.slice" DropInName = "socialweb-delegate.conf" AppArmorPath = "/etc/apparmor.d/" + hostcontract.AppArmorProfileName NodePassword = "/etc/rancher/node/password" LingerDir = "/var/lib/systemd/linger" RestrictUserns = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns" WaitReport = 10 * time.Second DefaultReadyBudget = bootstrap.DefaultReadyTimeout DefaultNetworkBudget = 3 * time.Minute MaxReadyBudget = 10 * time.Minute MaxNetworkBudget = 30 * time.Minute HostCheckBudget = 2 * time.Minute CommandBudget = time.Minute MinImageBudget = 30 * time.Second StartEndedGrace = 6 * time.Second BidderStartGrace = 6 * time.Minute RequesterStartGrace = time.Hour StartJobGrace = time.Minute AppArmorLoaded = "/sys/kernel/security/apparmor/profiles" UserUnitDir = ".config/systemd/user" WantsDir = "default.target.wants" RuntimePrefix = hostsvc.NamePrefix RuntimeSliceTop = "socialweb" ) var ( ErrProcBackend = errors.New("install: install places production units on the systemd-user backend only; host-backend proc is the dev run mode (socialweb-computer dev up)") ErrForeignAccount = errors.New("install: refusing to take over an existing account no install record says install created") ErrManagerWithoutKVM = errors.New("install: the account's user manager already runs without the kvm group") ErrVMsRunning = errors.New("uninstall: VM units of the account are active; stopping one makes its VM Lost") ErrNoRecord = errors.New("uninstall: no install record for this account") ErrUsage = errors.New("install: refused the arguments") ErrBinaryMismatch = errors.New("install: bin/current is not the binary this install was invoked as") ErrUnsupported = errors.New("install: install and uninstall are Linux root steps; this OS has no backend (plans 0011, 0012)") handleRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) accountRE = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) stateRE = regexp.MustCompile(`^/[A-Za-z0-9._/-]+$`) requesterNameRE = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,30}[a-z0-9])?$`) ) type Account struct { Name string UID int GID int Home string } type UserManager interface { Reload(ctx context.Context) error EnableNow(ctx context.Context, units []string) error Restart(ctx context.Context, units []string) error DisableStop(ctx context.Context, units []string) error List(ctx context.Context) ([]hostsvc.UnitStatus, error) Watch(ctx context.Context, unit string) (<-chan hostsvc.UnitStatus, error) Stop(ctx context.Context, unit string) error Close() } type System interface { LookupAccount(name string) (Account, bool, error) CreateAccount(name, home string) error DeleteAccount(name string) error AddToGroup(account, group string) error RemoveFromGroup(account, group string) error Chown(path string, uid, gid int) error Owner(path string) (int, int, error) ReloadSystemManager(ctx context.Context) error LoadAppArmor(path string) error UnloadAppArmor(path string) error SetLinger(ctx context.Context, a Account, on bool) error User(ctx context.Context, a Account) (UserManager, error) CheckHost(ctx context.Context, a Account, binary string, role hostcontract.Role, stateDir string) error } type Options struct { Role hostcontract.Role Handles []string Names []string RequesterRecords map[string][]byte Account string StateDir string Binary string VMSlots int DryRun bool AdoptAccount bool HostBackend string Root string Staged bool UnitPrefix string ReadyTimeout string NetworkTimeout string GuestImageBase string GuestImageBaseResolved bool Settings map[string]string Out io.Writer } const ( OptKcpFront = "kcp-front-address" OptTLSFront = "tls-front-address" OptCertFront = "cert-front-address" OptSpecFile = "gateway-spec-file" OptGuestImg = "guest-image-source" OptGuestImgBase = "guest-image-release-base" OptReadyTimeout = "ready-timeout" OptNetworkTimeout = "network-timeout" OptNoGuestLANUDP = "no-guest-lan-udp" OptGuestHostUDP = "guest-host-udp" OptBidderPolicy = "bidder-policy" OptBidderOperator = "bidder-operator-signin" OptComputeKinds = "compute-kinds" ) var GatewayListen = map[string]string{OptKcpFront: "127.0.0.1:6443", OptTLSFront: "127.0.0.1:8443", OptCertFront: "127.0.0.1:8444"} var Passed = []string{OptKcpFront, OptTLSFront, OptCertFront, OptSpecFile, OptGuestImg, OptGuestImgBase, OptNoGuestLANUDP, OptGuestHostUDP, OptBidderPolicy, OptBidderOperator, OptComputeKinds} func Rooted(root string) bool { return root != "" && filepath.Clean(root) != "/" } func prefixOr(p string) string { if p == "" { return hostsvc.NamePrefix } return p } type StepResult struct { Name string `json:"name"` Outcome string `json:"outcome"` Detail string `json:"detail,omitempty"` } type Report struct { Steps []StepResult `json:"steps"` Changes []string `json:"changes"` } func DefaultAccount(role hostcontract.Role) string { switch role { case hostcontract.RoleBidder: return AccountBidder case hostcontract.RoleRequester: return AccountRequester } return AccountGateway } func RecordPath(account string) string { return filepath.Join(StateRoot, "install-"+account+".json") } func HandleID(handle string) string { s := sha256.Sum256([]byte(handle)) return hex.EncodeToString(s[:])[:10] } func BidderLayout(state, prefix, handle string) devinstance.Layout { hid := HandleID(handle) dir := filepath.Join(state, "handles", hid) kcp := filepath.Join(dir, "kcp") run := filepath.Join(kcp, "run") return devinstance.Layout{ID: "bidder-" + hid, Short: hid, StateDir: dir, RuntimeDir: run, Socket: filepath.Join(run, "sup.sock"), SupState: filepath.Join(dir, "sup"), Record: filepath.Join(dir, "bidder.json"), CoreRecord: filepath.Join(dir, "core.json"), Lock: filepath.Join(kcp, "instance.lock"), CoreLock: filepath.Join(kcp, "core.lock"), Logs: filepath.Join(dir, "logs"), KineDir: filepath.Join(kcp, "kine-a"), KcpRoot: filepath.Join(kcp, "slot-a"), Prefix: prefix} } func RequesterKey(name string) string { return "req-" + HandleID(name) } func RequesterLayout(state, prefix, name string) devinstance.Layout { key := RequesterKey(name) dir := bootstrap.RequesterInstanceDir(state, name) kcp := filepath.Join(dir, "kcp") run := filepath.Join(kcp, "run") return devinstance.Layout{ID: "requester-" + HandleID(name), Short: key, StateDir: dir, RuntimeDir: run, Socket: filepath.Join(run, "sup.sock"), SupState: filepath.Join(dir, "sup"), Record: filepath.Join(dir, "instance.json"), CoreRecord: filepath.Join(dir, "core.json"), Lock: filepath.Join(kcp, "instance.lock"), CoreLock: filepath.Join(kcp, "core.lock"), Logs: filepath.Join(dir, "logs"), KineDir: filepath.Join(kcp, "kine-a"), KcpRoot: filepath.Join(kcp, "slot-a"), Prefix: prefix} } func RequesterReadyFile(state, name string) string { return filepath.Join(state, bootstrap.RequestersDir, name, bootstrap.RequesterReadyFile) } func CoreUnits(role hostcontract.Role, prefix string, handles []string) []string { if role == hostcontract.RoleGateway { return []string{table.CoreTemplate(prefix, table.RoleGateway)} } core := table.BidderCore if role == hostcontract.RoleRequester { core = table.RequesterCore } out := make([]string, 0, len(handles)) for _, h := range handles { out = append(out, prefixOr(prefix)+core+h+hostsvc.ServiceSuffix) } sort.Strings(out) return out } type installer struct { ctx context.Context o Options sys System rep *Report rec *Record recPath string acct Account known bool cfgWrote bool wrote map[string]bool planned map[string]bool mu sync.Mutex } func within(p, dir string) bool { return p == dir || strings.HasPrefix(p, dir+"/") } func (in *installer) path(p string) string { if in.o.Staged && in.o.StateDir != "" && (within(p, in.o.StateDir) || within(in.o.StateDir, p)) { return p } return filepath.Join(in.o.Root, p) } func (in *installer) out() io.Writer { if in.o.Out == nil { return io.Discard } return in.o.Out } func (in *installer) printf(format string, a ...any) { in.mu.Lock() defer in.mu.Unlock() fmt.Fprintf(in.out(), format, a...) } func (in *installer) change(desc string, f func() error) error { in.rep.Changes = append(in.rep.Changes, desc) if in.o.DryRun { in.printf("would %s\n", desc) return nil } in.printf("%s\n", desc) if err := f(); err != nil { return fmt.Errorf("install: %s: %w", desc, err) } return nil } func (in *installer) wait(desc string, f func() error) error { if in.o.DryRun { in.printf("would %s\n", desc) return nil } started := time.Now() in.printf("%s\n", desc) stop := make(chan struct{}) var ticking sync.WaitGroup ticking.Go(func() { t := time.NewTicker(WaitReport) defer t.Stop() for { select { case <-stop: return case <-t.C: in.printf(" ... %s\n", progress.Elapsed(time.Since(started))) } } }) err := f() close(stop) ticking.Wait() if err != nil { in.printf(" failed after %s\n", progress.Elapsed(time.Since(started))) return fmt.Errorf("install: %s: %w", desc, err) } in.printf(" done after %s\n", progress.Elapsed(time.Since(started))) return nil } func (in *installer) changeWait(desc string, f func() error) error { in.rep.Changes = append(in.rep.Changes, desc) return in.wait(desc, f) } func (in *installer) step(name, outcome, detail string) { in.rep.Steps = append(in.rep.Steps, StepResult{Name: name, Outcome: outcome, Detail: detail}) if outcome == OutcomeNotNeeded { return } in.rec.set(name, outcome) } func (in *installer) saveRecord() error { if in.o.DryRun { return nil } b := in.rec.bytes() if sameBytes(in.recPath, b) { return in.ensureOwner(in.recPath, recordMode, 0, 0) } return in.change("write the install record "+RecordPath(in.rec.Account), func() error { if err := os.MkdirAll(filepath.Dir(in.recPath), 0o755); err != nil { return err } return writeAtomic(in.recPath, b, recordMode) }) } func writeAtomic(path string, b []byte, mode os.FileMode) error { tmp, err := os.CreateTemp(filepath.Dir(path), ".swc-install-") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(b); err != nil { tmp.Close() return err } if err := tmp.Chmod(mode); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), path) } func (in *installer) ensureOwner(full string, mode os.FileMode, uid, gid int) error { st, err := os.Lstat(full) if err != nil { return err } if st.Mode().Perm() != mode.Perm() && st.Mode()&fs.ModeSymlink == 0 { if err := in.change(fmt.Sprintf("chmod %o %s", mode.Perm(), strings.TrimPrefix(full, in.o.Root)), func() error { return os.Chmod(full, mode) }); err != nil { return err } } if uid < 0 { return nil } ou, og, err := in.sys.Owner(full) if err != nil { return err } if ou == uid && og == gid { return nil } return in.change(fmt.Sprintf("chown %d:%d %s", uid, gid, strings.TrimPrefix(full, in.o.Root)), func() error { return in.sys.Chown(full, uid, gid) }) } func (in *installer) ensureDir(host string, mode os.FileMode, uid, gid int, track bool) error { full := in.path(host) if in.planned[host] { return nil } if _, err := os.Stat(full); errors.Is(err, fs.ErrNotExist) { parent := filepath.Dir(host) if parent != host { if _, err := os.Stat(in.path(parent)); errors.Is(err, fs.ErrNotExist) { if err := in.ensureDir(parent, 0o755, 0, 0, track); err != nil { return err } } } if err := in.change("create directory "+host, func() error { return os.Mkdir(full, mode) }); err != nil { return err } if track { in.rec.addDir(host) } if in.o.DryRun { if in.planned == nil { in.planned = map[string]bool{} } in.planned[host] = true return nil } } return in.ensureOwner(full, mode, uid, gid) } func (in *installer) ensureFile(host string, content []byte, mode os.FileMode, uid, gid int) (bool, error) { full := in.path(host) if sameBytes(full, content) { return false, in.ensureOwner(full, mode, uid, gid) } err := in.change("write "+host, func() error { if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return err } return writeAtomic(full, content, mode) }) if err != nil || in.o.DryRun { return true, err } return true, in.ensureOwner(full, mode, uid, gid) } func (in *installer) read(host string) string { b, _ := os.ReadFile(in.path(host)) return string(b) } func validate(o *Options) error { if o.HostBackend != "" && o.HostBackend != string(hostsvc.BackendSystemdUser) { if o.HostBackend == string(hostsvc.BackendProc) { return ErrProcBackend } return fmt.Errorf("install: host-backend %q: install writes %s", o.HostBackend, hostsvc.BackendSystemdUser) } if _, err := hostcontract.ParseRole(string(o.Role)); err != nil { return err } if o.Account == "" { o.Account = DefaultAccount(o.Role) } if !accountRE.MatchString(o.Account) { return fmt.Errorf("install: account name %q is not a valid system account name", o.Account) } if o.StateDir == "" { o.StateDir = filepath.Join(StateRoot, o.Account) } if !stateRE.MatchString(o.StateDir) || filepath.Clean(o.StateDir) != o.StateDir { return fmt.Errorf("install: state dir %q must be a clean absolute path of [A-Za-z0-9._/-] (it is written into unit files)", o.StateDir) } o.VMSlots = cmp.Or(o.VMSlots, DefaultVMSlots) if o.VMSlots < 1 { return fmt.Errorf("install: --vm-slots %d: want at least 1", o.VMSlots) } o.Root = cmp.Or(o.Root, "/") if o.UnitPrefix != "" { if err := hostsvc.ValidatePrefix(o.UnitPrefix); err != nil { return fmt.Errorf("install: unit-prefix: %w", err) } if !strings.HasSuffix(o.UnitPrefix, "-") { return fmt.Errorf("install: unit-prefix %q must end in -", o.UnitPrefix) } } if o.Role != hostcontract.RoleRequester && len(o.Names) > 0 { return fmt.Errorf("install %s takes no --name", o.Role) } switch o.Role { case hostcontract.RoleGateway: if len(o.Handles) > 0 { return fmt.Errorf("install gateway takes no --handle") } case hostcontract.RoleRequester: if len(o.Handles) > 0 { return fmt.Errorf("install requester takes no --handle; --name names each requester instance") } if len(o.Names) == 0 { return fmt.Errorf("install requester needs at least one --name (the requester instance, run requester --name)") } for _, n := range o.Names { if !requesterNameRE.MatchString(n) { return fmt.Errorf("install: requester name %q is not a DNS label of at most 32 characters", n) } if len(o.RequesterRecords[n]) == 0 { return fmt.Errorf("install: requester %s has no requester record to write", n) } } case hostcontract.RoleBidder: if len(o.Handles) == 0 { return fmt.Errorf("install bidder needs at least one --handle (the atproto handle each bidder instance runs as)") } for _, h := range o.Handles { if !handleRE.MatchString(h) || slices.ContainsFunc(strings.Split(h, "."), func(l string) bool { return len(l) > 63 }) { return fmt.Errorf("install: handle %q is not a lowercase atproto handle", h) } } } if o.Binary == "" || !filepath.IsAbs(o.Binary) { return fmt.Errorf("install: the binary to install must be an absolute path, got %q", o.Binary) } return nil } func Install(ctx context.Context, o Options, sys System) (Report, error) { rep := Report{} if err := validate(&o); err != nil { return rep, fmt.Errorf("%w: %w", ErrUsage, err) } in := &installer{ctx: ctx, o: o, sys: sys, rep: &rep, recPath: filepath.Join(o.Root, RecordPath(o.Account))} rec, _, err := loadOrNewRecord(in.recPath, o.Role, o.Account, o.StateDir) if err != nil { return rep, err } if rec.Role != o.Role { return rep, fmt.Errorf("install: %s was installed as a %s; uninstall it first", o.Account, rec.Role) } if rec.StateDir != o.StateDir { return rep, fmt.Errorf("install: %s was installed with state dir %s, not %s", o.Account, rec.StateDir, o.StateDir) } if len(rec.Steps) > 0 && rec.UnitPrefix != o.UnitPrefix { return rep, fmt.Errorf("install: %s was installed with unit prefix %q, not %q", o.Account, prefixOr(rec.UnitPrefix), prefixOr(o.UnitPrefix)) } rec.UnitPrefix = o.UnitPrefix in.rec = rec if err := in.checkBudgets(); err != nil { return rep, fmt.Errorf("%w: %w", ErrUsage, err) } steps := []func() error{in.account, in.subids, in.kvm, in.delegation, in.apparmor, in.nodePassword, in.stateLayout, in.guestImage, in.unitFiles, in.requesterRecords, in.linger, in.enable, in.bringUp, in.checkHost} for _, s := range steps { if err := s(); err != nil { return rep, err } if err := in.saveRecord(); err != nil { return rep, err } } if o.DryRun { in.printf("dry run: %d change(s) computed, nothing written\n", len(rep.Changes)) } return rep, nil } func (in *installer) account() error { name := in.o.Account a, ok, err := in.sys.LookupAccount(name) if err != nil { return err } if ok { switch { case in.rec.Steps[StepAccount] == OutcomeCreated || in.rec.Steps[StepAccount] == OutcomeAdopted: in.step(StepAccount, OutcomeFound, fmt.Sprintf("%s uid %d (recorded %s)", name, a.UID, in.rec.Steps[StepAccount])) case !in.o.AdoptAccount: return fmt.Errorf("%w: %s (uid %d) exists and %s does not record it; pass --adopt-account only if this account is meant for socialweb-computer", ErrForeignAccount, name, a.UID, RecordPath(name)) default: in.step(StepAccount, OutcomeAdopted, fmt.Sprintf("%s uid %d adopted (--adopt-account)", name, a.UID)) } in.acct, in.known = a, true in.rec.UID = a.UID return nil } err = in.change(fmt.Sprintf("create system account %s (home %s, no password, no login shell)", name, in.o.StateDir), func() error { return in.sys.CreateAccount(name, in.o.StateDir) }) if err != nil { return err } in.step(StepAccount, OutcomeCreated, name) if in.o.DryRun { in.acct = Account{Name: name, UID: -1, GID: -1, Home: in.o.StateDir} return nil } if a, ok, err = in.sys.LookupAccount(name); err != nil || !ok { return fmt.Errorf("install: %s was created but does not resolve: %v", name, err) } in.acct, in.known = a, true in.rec.UID = a.UID if err := in.saveRecord(); err != nil { return err } return nil } func (in *installer) uidLabel() string { if !in.known { return "" } return strconv.Itoa(in.acct.UID) } func maxEnd(contents ...string) uint64 { var end uint64 for _, c := range contents { for _, e := range hostcontract.ParseSubids(c) { if e.End() > end { end = e.End() } } } return end } func NextRangeStart(subuid, subgid string) uint64 { start := max(maxEnd(subuid, subgid), SubidFloor) return (start + hostcontract.VMSlotIDs - 1) / hostcontract.VMSlotIDs * hostcontract.VMSlotIDs } func (in *installer) subids() error { if in.o.Role != hostcontract.RoleBidder { return nil } files := []struct{ step, host string }{{StepSubuid, "/etc/subuid"}, {StepSubgid, "/etc/subgid"}} sub, sg := in.read("/etc/subuid"), in.read("/etc/subgid") start := NextRangeStart(sub, sg) count := uint64(in.o.VMSlots) * hostcontract.VMSlotIDs reusedFrom := "" for i, content := range []string{sub, sg} { if r, ok := hostcontract.AccountRange(content, in.o.Account, in.acct.UID); ok { start, count = r.Start, r.End()-r.Start reusedFrom = files[i].host break } } for _, f := range files { content := in.read(f.host) if r, ok := hostcontract.AccountRange(content, in.o.Account, in.acct.UID); ok { in.step(f.step, OutcomeFound, fmt.Sprintf("%d-%d", r.Start, r.End()-1)) continue } if reusedFrom != "" { want := hostcontract.SubidEntry{Start: start, Count: count} for _, e := range hostcontract.ParseSubids(content) { if hostcontract.Overlaps(want, e) { detail := fmt.Sprintf("the range %d-%d of %s in %s overlaps %s:%d:%d on line %d of %s", start, start+count-1, in.o.Account, reusedFrom, e.Owner, e.Start, e.Count, e.Line+1, f.host) in.step(f.step, OutcomeRefused, detail) return fmt.Errorf("install: %s; give %s one range that is free in both /etc/subuid and /etc/subgid", detail, in.o.Account) } } } line := fmt.Sprintf("%s:%d:%d\n", in.o.Account, start, count) next := content if next != "" && !strings.HasSuffix(next, "\n") { next += "\n" } next += line full := in.path(f.host) mode := os.FileMode(0o644) if st, err := os.Stat(full); err == nil { mode = st.Mode().Perm() } if in.rec.SubidLines == nil { in.rec.SubidLines = map[string]string{} } in.rec.SubidLines[f.host] = line in.rec.set(f.step, OutcomeCreated) if err := in.saveRecord(); err != nil { return err } err := in.change(fmt.Sprintf("reserve %s in %s (%d VM slots of %d ids)", strings.TrimSpace(line), f.host, in.o.VMSlots, hostcontract.VMSlotIDs), func() error { return writeAtomic(full, []byte(next), mode) }) if err != nil { return err } in.step(f.step, OutcomeCreated, strings.TrimSpace(line)) } return nil } func (in *installer) kvm() error { if in.o.Role != hostcontract.RoleBidder { return nil } gid, member := hostcontract.GroupMembership(in.read("/etc/group"), KVMGroup, in.o.Account) if gid < 0 { return fmt.Errorf("install: no %s group in /etc/group: load kvm_intel or kvm_amd and install the distribution's kvm/udev rules (never chmod /dev/kvm)", KVMGroup) } if member { in.step(StepKVM, OutcomeFound, fmt.Sprintf("%s in %s (gid %d)", in.o.Account, KVMGroup, gid)) } else { if err := in.change("add "+in.o.Account+" to group "+KVMGroup+" (before the user manager first starts)", func() error { return in.sys.AddToGroup(in.o.Account, KVMGroup) }); err != nil { return err } in.step(StepKVM, OutcomeCreated, fmt.Sprintf("gid %d", gid)) } if !in.known { return nil } if in.o.Staged { return nil } pid, groups, notRunning := hostcontract.ManagerGroups(in.o.Root, in.acct.UID) if notRunning != "" { return nil } if slices.Contains(groups, gid) { return nil } if err := in.saveRecord(); err != nil { return err } return fmt.Errorf("%w: user@%d (pid %d) started before %s joined %s; restart user@%d - this stops every VM - or reboot, then re-run install (install never restarts it)", ErrManagerWithoutKVM, in.acct.UID, pid, in.o.Account, KVMGroup, in.acct.UID) } const statePlaceholder = "{{.State}}" const startTimeoutPlaceholder = "{{.StartTimeout}}" func render(name string, data unitData) ([]byte, error) { raw, err := templates.ReadFile("templates/" + name) if err != nil { return nil, err } out := strings.ReplaceAll(string(raw), statePlaceholder, data.State) out = strings.ReplaceAll(out, startTimeoutPlaceholder, data.StartTimeout) if strings.Contains(out, "{{") { return nil, fmt.Errorf("install: template %s has a placeholder other than %s or %s", name, statePlaceholder, startTimeoutPlaceholder) } return []byte(out), nil } type unitData struct { State string StartTimeout string } func StartTimeoutFor(readyBudget time.Duration) time.Duration { return readyBudget + BidderStartGrace } func RenderUnits(role hostcontract.Role, state, prefix string, startTimeout time.Duration) (map[string][]byte, error) { out := map[string][]byte{} core, tmpl := table.CoreTemplate(prefix, table.RoleGateway), GatewayUnit switch role { case hostcontract.RoleBidder: core, tmpl = table.CoreTemplate(prefix, table.RoleBidder), BidderTemplate case hostcontract.RoleRequester: core, tmpl = table.CoreTemplate(prefix, table.RoleRequester), RequesterTemplate } data := unitData{State: state, StartTimeout: strconv.Itoa(int(startTimeout.Seconds())) + "s"} for name, src := range map[string]string{SliceUnit: SliceUnit, core: tmpl} { b, err := render(src, data) if err != nil { return nil, err } out[name] = b } return out, nil } func DropIn() ([]byte, error) { return render(DropInName, unitData{}) } func AppArmorProfile(state string) ([]byte, error) { return render(hostcontract.AppArmorProfileName, unitData{State: state}) } func (in *installer) delegation() error { dir := "/etc/systemd/system/user@" + in.uidLabel() + ".service.d" content, err := DropIn() if err != nil { return err } if err := in.ensureDir(dir, 0o755, 0, 0, true); err != nil { return err } changed, err := in.ensureFile(filepath.Join(dir, DropInName), content, 0o644, 0, 0) if err != nil { return err } if !changed { in.step(StepDelegation, OutcomeFound, dir) return nil } in.step(StepDelegation, OutcomeCreated, dir+"/"+DropInName+": Delegate="+hostcontract.DelegatedControllers) return in.change("systemctl daemon-reload (system manager, for the delegation drop-in)", func() error { return in.sys.ReloadSystemManager(in.ctx) }) } func (in *installer) apparmor() error { if in.o.Role != hostcontract.RoleBidder { return nil } if strings.TrimSpace(in.read(RestrictUserns)) != "1" { in.step(StepAppArmor, OutcomeNotNeeded, "AppArmor does not restrict unprivileged user namespaces") return nil } content, err := AppArmorProfile(in.o.StateDir) if err != nil { return err } changed, err := in.ensureFile(AppArmorPath, content, 0o644, 0, 0) if err != nil { return err } if changed { in.step(StepAppArmor, OutcomeCreated, AppArmorPath) } else { in.step(StepAppArmor, OutcomeFound, AppArmorPath) } if !changed && hostcontract.AppArmorProfileLoaded(in.read(AppArmorLoaded), hostcontract.AppArmorProfileName) { return nil } return in.change("apparmor_parser -r "+AppArmorPath, func() error { return in.sys.LoadAppArmor(in.path(AppArmorPath)) }) } func (in *installer) nodePassword() error { if in.o.Role != hostcontract.RoleGateway { return nil } full := in.path(NodePassword) if _, err := os.Stat(full); err == nil { in.step(StepPassword, OutcomeFound, NodePassword) if in.o.DryRun || !in.known { return nil } ou, og, err := in.sys.Owner(full) if err != nil { return err } if ou != in.acct.UID { if in.rec.PrevOwner == nil { in.rec.PrevOwner = map[string][2]int{} } if _, kept := in.rec.PrevOwner[NodePassword]; !kept { in.rec.PrevOwner[NodePassword] = [2]int{ou, og} } in.rec.set(StepPassword, OutcomeUpdated) } return in.ensureOwner(full, 0o600, in.acct.UID, in.acct.GID) } if err := in.ensureDir(filepath.Dir(NodePassword), 0o755, 0, 0, true); err != nil { return err } secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return err } if _, err := in.ensureFile(NodePassword, []byte(hex.EncodeToString(secret)), 0o600, in.acct.UID, in.acct.GID); err != nil { return err } in.step(StepPassword, OutcomeCreated, NodePassword+" (0600, the k3s node password)") return nil } func (in *installer) stateLayout() error { uid, gid := in.acct.UID, in.acct.GID state := in.o.StateDir if err := in.ensureDir(filepath.Dir(state), 0o755, 0, 0, true); err != nil { return err } _, statErr := os.Stat(in.path(state)) if err := in.ensureDir(state, 0o700, uid, gid, true); err != nil { return err } if errors.Is(statErr, fs.ErrNotExist) { in.step(StepStateDir, OutcomeCreated, state) } else { in.step(StepStateDir, OutcomeFound, state) } for _, d := range []string{"bin", "runtime"} { if err := in.ensureDir(filepath.Join(state, d), 0o755, uid, gid, false); err != nil { return err } } if err := in.binary(); err != nil { return err } return in.config() } func (in *installer) binary() error { sum, err := sha256sum.File(in.o.Binary) if err != nil { return fmt.Errorf("install: hashing %s: %w", in.o.Binary, err) } bin := filepath.Join(in.o.StateDir, "bin") current := filepath.Join(bin, "current") if target, err := os.Readlink(in.path(current)); err == nil { if _, err := os.Stat(in.path(filepath.Join(bin, target, CoreName))); err == nil { if target != sum { in.step(StepBinary, OutcomeRefused, "bin/current -> "+target+"; this binary is "+sum) dev := "" if buildstamp.Read() != nil { dev = " --allow-dev-binary" } return fmt.Errorf("%w: install writes a unit that starts bin/current, and this binary is the one "+ "that sends the readiness that unit waits for; switch it first by running, as %s, "+ "\"upgrade --binary %s --sha256 %s --state-dir %s%s\", then run install again", ErrBinaryMismatch, in.o.Account, in.o.Binary, sum, in.o.StateDir, dev) } in.step(StepBinary, OutcomeFound, "bin/current -> "+target) return nil } } dir := filepath.Join(bin, sum) if err := in.ensureDir(dir, 0o755, in.acct.UID, in.acct.GID, false); err != nil { return err } dst := filepath.Join(dir, CoreName) if have, _ := sha256sum.File(in.path(dst)); have != sum { if err := in.change("copy "+in.o.Binary+" to "+dst, func() error { return copyFile(in.o.Binary, in.path(dst), 0o755) }); err != nil { return err } } if !in.o.DryRun { if err := in.ensureOwner(in.path(dst), 0o755, in.acct.UID, in.acct.GID); err != nil { return err } } if err := in.change("point "+current+" at "+sum, func() error { return retarget(in.path(current), sum) }); err != nil { return err } in.step(StepBinary, OutcomeCreated, "bin/"+sum+"/"+CoreName) return nil } func retarget(link, target string) error { tmp := link + ".next" _ = os.Remove(tmp) if err := os.Symlink(target, tmp); err != nil { return err } return os.Rename(tmp, link) } func copyFile(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() tmp := dst + ".partial" out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(tmp) return err } if err := out.Close(); err != nil { return err } return os.Rename(tmp, dst) } func (in *installer) config() error { p := filepath.Join(in.o.StateDir, "config.json") cfg := map[string]any{} if b, err := os.ReadFile(in.path(p)); err == nil { if err := json.Unmarshal(b, &cfg); err != nil { return fmt.Errorf("install: %s is not valid JSON: %w", p, err) } } cfg["host-backend"] = string(hostsvc.BackendSystemdUser) cfg["state-dir"] = in.o.StateDir cfg["asset-cache"] = filepath.Join(in.o.StateDir, "runtime") if in.o.UnitPrefix != "" { cfg["unit-prefix"] = in.o.UnitPrefix } else { delete(cfg, "unit-prefix") } if in.o.Role == hostcontract.RoleBidder { if s, _ := cfg["profile"].(string); s == "" { cfg["profile"] = BidderProfile } if s, _ := cfg[OptBidderPolicy].(string); strings.TrimSpace(s) == "" { cfg[OptBidderPolicy] = v1alpha1.BidderDefaultPolicy } } if in.o.Role == hostcontract.RoleGateway { for k, v := range GatewayListen { if s, _ := cfg[k].(string); s == "" { cfg[k] = v } } } cfg[OptReadyTimeout] = in.readyBudget().String() cfg[OptNetworkTimeout] = in.networkBudget().String() for k, v := range in.o.Settings { if v == "" || k == OptReadyTimeout || k == OptNetworkTimeout { continue } cfg[k] = v } b, _ := json.MarshalIndent(cfg, "", " ") changed, err := in.ensureFile(p, append(b, '\n'), 0o644, in.acct.UID, in.acct.GID) if err != nil { return err } if changed { in.cfgWrote = true in.step(StepConfig, OutcomeCreated, p+": host-backend "+string(hostsvc.BackendSystemdUser)) } else { in.step(StepConfig, OutcomeFound, p) } return nil } func (in *installer) instances() []string { if in.o.Role == hostcontract.RoleRequester { return in.o.Names } return in.o.Handles } func (in *installer) requesterRecords() error { if in.o.Role != hostcontract.RoleRequester { return nil } uid, gid := in.acct.UID, in.acct.GID var created, found []string for _, n := range in.o.Names { dir := filepath.Join(in.o.StateDir, bootstrap.RequestersDir, n) for _, d := range []string{filepath.Dir(dir), dir} { if err := in.ensureDir(d, 0o700, uid, gid, false); err != nil { return err } } p := filepath.Join(dir, bootstrap.RequesterRecordFile) if _, err := os.Stat(in.path(p)); err == nil { found = append(found, p) continue } if _, err := in.ensureFile(p, in.o.RequesterRecords[n], 0o600, uid, gid); err != nil { return err } created = append(created, p) } switch { case len(created) > 0: in.step(StepRequesterRecord, OutcomeCreated, strings.Join(created, " ")) default: in.step(StepRequesterRecord, OutcomeFound, strings.Join(found, " ")+" (kept: the requester's first start options)") } return nil } func (in *installer) requesterBringUp() error { files := make([]string, 0, len(in.o.Names)) for _, n := range in.o.Names { files = append(files, RequesterReadyFile(in.o.StateDir, n)) } if in.o.DryRun { in.step(StepBringUp, OutcomeCreated, "would check "+strings.Join(files, ", ")) in.printf("would check the ready file %s\n", strings.Join(files, ", ")) return nil } for _, f := range files { if st, err := os.Stat(in.path(f)); err != nil || st.Size() == 0 { in.step(StepBringUp, OutcomeRefused, f+" is not written") return fmt.Errorf("install: %s is not written although its unit is active (the unit sends READY only after it); read %s", f, journalHint(in.o.Account, in.acct.UID, CoreUnits(in.o.Role, in.o.UnitPrefix, in.o.Names)...)) } } in.step(StepBringUp, OutcomeFound, strings.Join(files, ", ")) return nil } func (in *installer) bringUp() error { if in.o.Role == hostcontract.RoleRequester { return in.requesterBringUp() } if in.o.Role != hostcontract.RoleBidder { in.step(StepBringUp, OutcomeNotNeeded, "the gateway answers for itself") return nil } if in.o.DryRun { in.step(StepBringUp, OutcomeCreated, "would wait for the kcp slot of "+strings.Join(in.o.Handles, ", ")) in.printf("would wait for the kcp slot of %s to answer /readyz\n", strings.Join(in.o.Handles, ", ")) return nil } budget := in.readyBudget() details := make([]string, 0, len(in.o.Handles)) skipped := make([]string, 0, len(in.o.Handles)) for _, handle := range in.o.Handles { l := BidderLayout(in.o.StateDir, prefixOr(in.o.UnitPrefix), handle) slots, err := kcpserver.ReadSlots(in.path(l.SlotsFile())) if err != nil { skipped = append(skipped, handle+": "+err.Error()) continue } slot, ok := slots.Slots[slots.Active] if !ok || slot.Port == 0 { skipped = append(skipped, handle+": the active slot records no port") continue } port := strconv.Itoa(slot.Port) ctx, cancel := context.WithTimeout(in.ctx, budget) reported := "" err = in.wait("waiting for the kcp slot of "+handle+" on 127.0.0.1:"+port, func() error { _, err := kcpserver.WaitReady(ctx, slot.Port, in.path(l.AdminKubeconfig()), time.Time{}, kcpserver.ProbePoll, func(_ int, last error) { msg := "" if last != nil { msg = last.Error() } if msg == "" || msg == reported { return } reported = msg in.printf(" %s\n", msg) }) return err }) cancel() if err != nil { in.step(StepBringUp, OutcomeRefused, err.Error()) if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%w; raise --%s if this host's first start is slow, and read %s", err, OptReadyTimeout, journalHint(in.o.Account, in.acct.UID, CoreUnits(in.o.Role, in.o.UnitPrefix, in.o.Handles)...)) } return err } details = append(details, handle+" on 127.0.0.1:"+port) } if len(details) == 0 { for _, handle := range in.o.Handles { l := BidderLayout(in.o.StateDir, prefixOr(in.o.UnitPrefix), handle) if _, err := os.Stat(in.path(l.SlotsFile())); err == nil { in.step(StepBringUp, OutcomeRefused, "the core wrote a kcp slot record with no active port: "+strings.Join(skipped, "; ")) return fmt.Errorf("install: %s records no active kcp slot port; read %s", in.path(l.SlotsFile()), journalHint(in.o.Account, in.acct.UID, CoreUnits(in.o.Role, in.o.UnitPrefix, in.o.Handles)...)) } } in.step(StepBringUp, OutcomeNotNeeded, "no handle published an active kcp slot port: "+strings.Join(skipped, "; ")) return nil } if len(skipped) > 0 { in.step(StepBringUp, OutcomeRefused, "ready: "+strings.Join(details, ", ")+"; not ready: "+strings.Join(skipped, "; ")) return fmt.Errorf("install: %s came up and %s did not; read %s", strings.Join(details, ", "), strings.Join(skipped, "; "), journalHint(in.o.Account, in.acct.UID, CoreUnits(in.o.Role, in.o.UnitPrefix, in.o.Handles)...)) } in.step(StepBringUp, OutcomeFound, strings.Join(details, ", ")) return nil } func (in *installer) chownTree(host string, uid, gid int) error { return filepath.WalkDir(in.path(host), func(p string, _ fs.DirEntry, err error) error { if err != nil { return err } return in.sys.Chown(p, uid, gid) }) } func (in *installer) checkBudgets() error { for _, b := range []struct { opt, passed, why string min, max time.Duration }{ {OptReadyTimeout, in.o.ReadyTimeout, "that budget sizes the unit's start timeout and its watchdog, so " + "a longer one makes a wedged core wait longer to be restarted rather than giving a cold start more room", 0, MaxReadyBudget}, {OptNetworkTimeout, in.o.NetworkTimeout, "that budget is how long the guest image fetch may take, " + "a third of it, at least " + MinImageBudget.String() + ", is the search of the release base, and an " + "image that needs longer than either is better named with --" + OptGuestImg, MinImageBudget, MaxNetworkBudget}, } { for _, v := range []struct{ src, val string }{ {"--" + b.opt, b.passed}, {"config.json " + b.opt, in.storedSetting(b.opt)}, } { if v.val == "" { continue } d, err := time.ParseDuration(v.val) if err != nil { return fmt.Errorf("install: %s %q is not a duration: %w", v.src, v.val, err) } if d <= 0 { return fmt.Errorf("install: %s %q is not a positive duration", v.src, v.val) } if d < b.min { return fmt.Errorf("install: %s %q is under the minimum of %s, which is what the search of the "+ "release base needs to list a bucket rather than report a slow one as a missing image", v.src, v.val, b.min) } if d > b.max { return fmt.Errorf("install: %s %q is over the maximum of %s: %s", v.src, v.val, b.max, b.why) } break } } return nil } func (in *installer) budget(opt, passed string, def time.Duration) time.Duration { for _, v := range []string{passed, in.storedSetting(opt)} { if v == "" { continue } if d, err := time.ParseDuration(v); err == nil && d > 0 { return d } } return def } func (in *installer) readyBudget() time.Duration { return in.budget(OptReadyTimeout, in.o.ReadyTimeout, DefaultReadyBudget) } func (in *installer) networkBudget() time.Duration { return in.budget(OptNetworkTimeout, in.o.NetworkTimeout, DefaultNetworkBudget) } func (in *installer) waitStarted(um UserManager, unit string) error { ctx, cancel := context.WithTimeout(in.ctx, in.startBudget()) defer cancel() flags := journalHint(in.o.Account, in.acct.UID, unit) return in.wait("waiting for "+unit+" to finish starting", func() error { statuses, err := um.Watch(ctx, unit) if err != nil { return err } grace := time.NewTimer(StartEndedGrace) grace.Stop() defer grace.Stop() var ended hostsvc.UnitStatus for { select { case <-ctx.Done(): return fmt.Errorf("install: %s is still starting after %s; read %s", unit, in.startBudget(), flags) case <-grace.C: return fmt.Errorf("install: %s ended %s %s and stayed there for %s; read %s", unit, ended.State, ended.SubState, StartEndedGrace, flags) case s, open := <-statuses: if !open { if ctx.Err() != nil { continue } return fmt.Errorf("install: the watch of %s ended; read %s", unit, flags) } switch s.State { case hostsvc.Active: return nil case hostsvc.NotFound: return fmt.Errorf("install: %s is no longer known to systemd; read %s", unit, flags) case hostsvc.Activating: grace.Stop() ended = hostsvc.UnitStatus{} default: if ended.State == "" { grace.Reset(StartEndedGrace) } ended = s } } } }) } func journalHint(account string, uid int, units ...string) string { flags := make([]string, 0, len(units)) for _, u := range units { flags = append(flags, "_SYSTEMD_USER_UNIT="+u) } return fmt.Sprintf("journalctl _UID=%d %s", uid, strings.Join(flags, " ")) } func signinHint(state, name string) string { return fmt.Sprintf("%s run requester signin %s --state-dir %s", filepath.Join(state, "bin", "current", "socialweb-computer"), name, state) } func (in *installer) startTimeout() time.Duration { if in.o.Role == hostcontract.RoleRequester { return in.readyBudget() + RequesterStartGrace } return StartTimeoutFor(in.readyBudget()) } func (in *installer) startBudget() time.Duration { return in.startTimeout() + StartJobGrace } func storeVerifies(store guestimage.Store, pin guestimage.Pin) bool { _, err := store.Verify(pin) return err == nil } func (in *installer) unitDir() string { return filepath.Join(in.acct.Home, UserUnitDir) } func (in *installer) storedSetting(key string) string { b, err := os.ReadFile(in.path(filepath.Join(in.o.StateDir, "config.json"))) if err != nil { return "" } var cfg map[string]any if err := json.Unmarshal(b, &cfg); err != nil { return "" } s, _ := cfg[key].(string) return s } func (in *installer) guestImage() error { if in.o.Role == hostcontract.RoleRequester { in.step(StepGuestImage, OutcomeNotNeeded, "the requester runs no guest") return nil } if in.o.Role != hostcontract.RoleBidder { in.step(StepGuestImage, OutcomeNotNeeded, "the gateway runs no guest") return nil } target := vmruntime.ImageTarget() images := filepath.Join(in.o.StateDir, "images") pin, err := guestimage.PinFor(target) if err != nil { in.step(StepGuestImage, OutcomeRefused, err.Error()) return fmt.Errorf("install: this host cannot name the guest image it would boot: %w", err) } if err := pin.Valid(); err != nil { in.step(StepGuestImage, OutcomeRefused, err.Error()) return fmt.Errorf("install: %w", err) } root := in.path(images) store := guestimage.Store{Root: root, Out: in.out(), TTY: progress.IsTTYWriter(in.out())} if in.o.DryRun { if dir := store.Dir(pin); fsx.Exists(dir) { in.step(StepGuestImage, OutcomeFound, dir) in.printf("the guest image for %s is already in %s\n", target, dir) return nil } in.step(StepGuestImage, OutcomeCreated, "would fetch the guest image for "+target+" into "+in.path(images)) in.printf("would fetch the guest image for %s into %s\n", target, in.path(images)) return nil } if err := in.ensureDir(images, 0o700, in.acct.UID, in.acct.GID, true); err != nil { return err } lookup := max(in.networkBudget()/3, MinImageBudget) lookupCtx, lookupCancel := context.WithTimeout(in.ctx, lookup) defer lookupCancel() dir := store.Dir(pin) if fsx.Exists(dir) && storeVerifies(store, pin) { if err := in.chownTree(images, in.acct.UID, in.acct.GID); err != nil { return err } in.step(StepGuestImage, OutcomeFound, dir) return nil } if fsx.Exists(dir) { in.printf("the stored guest image no longer matches its pin; fetching it again\n") } source := in.o.Settings[OptGuestImg] if source == "" { source = in.storedSetting(OptGuestImg) } if source == "" { if in.o.GuestImageBase == "" && in.o.GuestImageBaseResolved { in.step(StepGuestImage, OutcomeRefused, "no guest image source and the release base was cleared") return fmt.Errorf("install: no --%s and --%s is empty, so no image can be named; name one with --%s", OptGuestImg, OptGuestImgBase, OptGuestImg) } if in.o.GuestImageBase == "" { in.step(StepGuestImage, OutcomeNotNeeded, "the caller named no guest image source and no release base") return nil } version, commit := buildstamp.Identity() s, err := guestimagerelease.Source(lookupCtx, guestimagerelease.Options{Base: in.o.GuestImageBase, Target: target, Version: version, Commit: commit, ManifestSHA256: pin.Files[guestimage.ManifestName].SHA256}) switch { case err == nil: source = s case errors.Is(err, guestimagerelease.ErrPinMismatch): in.step(StepGuestImage, OutcomeRefused, err.Error()) return fmt.Errorf("%w; name a matching image with --%s, or build one with scripts/build-guest-image.sh", err, OptGuestImg) case errors.Is(err, guestimagerelease.ErrNotPublished): in.step(StepGuestImage, OutcomeRefused, err.Error()) return fmt.Errorf("%w; a bidder with no guest image can provision nothing, so name one with --%s", err, OptGuestImg) default: in.step(StepGuestImage, OutcomeRefused, err.Error()) if errors.Is(err, context.Canceled) { return err } if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%w; raise --%s, of which this search is a third floored at %s, or name an "+ "image with --%s", err, OptNetworkTimeout, MinImageBudget, OptGuestImg) } return fmt.Errorf("%w; name an image with --%s, or check that %s answers this host", err, OptGuestImg, in.o.GuestImageBase) } } if fsx.Exists(dir) { if err := removeImageTree(root, dir); err != nil { return err } } in.printf("fetching the guest image for %s from %s\n", target, source) ctx, cancel := context.WithTimeout(in.ctx, in.networkBudget()) defer cancel() res, err := store.Ensure(ctx, pin, source) if err != nil { in.step(StepGuestImage, OutcomeRefused, err.Error()) if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%w; raise --%s if the image is large or the link slow, or name an image with --%s", err, OptNetworkTimeout, OptGuestImg) } return fmt.Errorf("%w; name a matching image with --%s, or build one with scripts/build-guest-image.sh", err, OptGuestImg) } outcome := OutcomeFound if res.Fetched { outcome = OutcomeCreated } if err := in.chownTree(images, in.acct.UID, in.acct.GID); err != nil { return err } in.step(StepGuestImage, outcome, res.Dir) return nil } func removeImageTree(root, dir string) error { rel, err := filepath.Rel(root, dir) if err != nil || rel == "." || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return fmt.Errorf("install: refusing to remove %s: it is not inside the image store %s", dir, root) } if len(filepath.Base(dir)) != guestimage.SHA256HexLen { return fmt.Errorf("install: refusing to remove %s: it is not named for a manifest sha256", dir) } realRoot, err := filepath.EvalSymlinks(root) if err != nil { return fmt.Errorf("install: resolving the image store %s: %w", root, err) } realDir, err := filepath.EvalSymlinks(filepath.Dir(dir)) if err != nil { return fmt.Errorf("install: resolving %s: %w", filepath.Dir(dir), err) } if under, err := filepath.Rel(realRoot, realDir); err != nil || under == ".." || filepath.IsAbs(under) || strings.HasPrefix(under, ".."+string(os.PathSeparator)) { return fmt.Errorf("install: refusing to remove %s: its directory resolves to %s, outside the image store %s", dir, realDir, realRoot) } if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("install: removing the unusable image at %s: %w", dir, err) } return nil } func (in *installer) unitFiles() error { units, err := RenderUnits(in.o.Role, in.o.StateDir, in.o.UnitPrefix, in.startTimeout()) if err != nil { return err } dir := in.unitDir() for _, d := range []string{filepath.Join(in.acct.Home, ".config"), filepath.Join(in.acct.Home, ".config/systemd"), dir} { if err := in.ensureDir(d, 0o755, in.acct.UID, in.acct.GID, false); err != nil { return err } } names := make([]string, 0, len(units)) for n := range units { names = append(names, n) } sort.Strings(names) any := false for _, n := range names { changed, err := in.ensureFile(filepath.Join(dir, n), units[n], 0o644, in.acct.UID, in.acct.GID) if err != nil { return err } in.rec.addUnitFile(filepath.Join(dir, n)) if changed { in.noteWritten(n) } any = any || changed } boot, bootChanged, err := in.bootstrapFiles(dir) if err != nil { return err } names = append(names, boot...) any = any || bootChanged if any { in.step(StepUnitFiles, OutcomeCreated, strings.Join(names, " ")) } else { in.step(StepUnitFiles, OutcomeFound, strings.Join(names, " ")) } return nil } func (in *installer) noteWritten(name string) { if in.wrote == nil { in.wrote = map[string]bool{} } in.wrote[name] = true } func coreTemplate(unit string) string { at := strings.Index(unit, "@") dot := strings.LastIndex(unit, ".") if at < 0 || dot < at { return "" } return unit[:at+1] + "." + unit[dot+1:] } func (in *installer) unitChanged(unit string) bool { if in.cfgWrote || in.wrote[unit] || in.wrote[coreTemplate(unit)] { return true } for p := range in.wrote { if strings.HasPrefix(p, unit+".d/") { return true } } return false } func (in *installer) linger() error { p := filepath.Join(LingerDir, in.o.Account) if _, err := os.Stat(in.path(p)); err == nil { in.step(StepLinger, OutcomeFound, p) return nil } if err := in.change("loginctl enable-linger "+in.o.Account+" (user@"+in.uidLabel()+" first starts here)", func() error { return in.sys.SetLinger(in.ctx, in.acct, true) }); err != nil { return err } in.step(StepLinger, OutcomeCreated, p) return nil } func (in *installer) enable() error { units := CoreUnits(in.o.Role, in.o.UnitPrefix, in.instances()) if in.o.DryRun && !in.known { for _, u := range units { in.rep.Changes = append(in.rep.Changes, "enable --now "+u) in.printf("would enable --now %s as %s\n", u, in.o.Account) } return nil } if in.o.DryRun { if _, err := os.Stat(in.path(filepath.Join(LingerDir, in.o.Account))); err != nil { for _, u := range units { in.rep.Changes = append(in.rep.Changes, "enable --now "+u) in.printf("would enable --now %s as %s\n", u, in.o.Account) } return nil } } um, err := in.sys.User(in.ctx, in.acct) if err != nil { return fmt.Errorf("install: reaching user@%d: %w", in.acct.UID, err) } defer um.Close() actx, acancel := context.WithTimeout(in.ctx, in.startBudget()) defer acancel() states := map[string]hostsvc.UnitStatus{} listed, err := um.List(actx) if err != nil { return err } for _, s := range listed { states[s.Name] = s } var todo, starting, restart []string for _, u := range units { _, linkErr := os.Lstat(in.path(filepath.Join(in.unitDir(), WantsDir, u))) st := states[u] switch { case linkErr != nil || (st.State != hostsvc.Active && st.State != hostsvc.Activating): todo = append(todo, u) case in.unitChanged(u): restart = append(restart, u) case st.State == hostsvc.Activating: starting = append(starting, u) } } in.rec.addHandles(in.o.Handles) in.rec.addNames(in.o.Names) if err := in.saveRecord(); err != nil { return err } if len(todo) == 0 && len(starting) == 0 && len(restart) == 0 { in.step(StepUnits, OutcomeFound, strings.Join(units, " ")) return nil } if err := in.change("systemctl --user daemon-reload as "+in.o.Account, func() error { return um.Reload(actx) }); err != nil { return err } if err := in.enableBootstrap(actx, um); err != nil { return err } if in.o.Role == hostcontract.RoleRequester && len(todo)+len(restart)+len(starting) > 0 { in.printf("a requester unit is active only after its sign-in and its ready file; the sign-in step (a QR or browser URL) is in %s -f (as root; journalctl --user as %s reads nothing, a system account logs into the system journal)\n", journalHint(in.o.Account, in.acct.UID, units...), in.o.Account) for _, name := range in.o.Names { in.printf("the sign-in URL of requester %s, as root or %s: %s\n", name, in.o.Account, signinHint(in.o.StateDir, name)) } } for _, u := range todo { sctx, cancel := context.WithTimeout(in.ctx, in.startBudget()) err := in.changeWait("enable --now "+u+" as "+in.o.Account, func() error { return um.EnableNow(sctx, []string{u}) }) cancel() if err != nil { in.step(StepUnits, OutcomeRefused, u+": "+err.Error()) return fmt.Errorf("%w; read %s", err, journalHint(in.o.Account, in.acct.UID, u)) } } for _, u := range restart { sctx, cancel := context.WithTimeout(in.ctx, in.startBudget()) err := in.changeWait("restart "+u+" as "+in.o.Account+" (its unit file or its config.json changed)", func() error { return um.Restart(sctx, []string{u}) }) cancel() if err != nil { in.step(StepUnits, OutcomeRefused, u+": "+err.Error()) return fmt.Errorf("%w; read %s", err, journalHint(in.o.Account, in.acct.UID, u)) } } for _, u := range starting { if err := in.waitStarted(um, u); err != nil { in.step(StepUnits, OutcomeRefused, u+": "+err.Error()) return err } } outcome := OutcomeCreated if len(todo) == 0 && len(starting) == 0 { outcome = OutcomeUpdated } in.step(StepUnits, outcome, strings.Join(units, " ")) return nil } func (in *installer) checkHost() error { exe := filepath.Join(in.o.StateDir, "bin", "current", CoreName) desc := "run check-host -role " + string(in.o.Role) + " as " + in.o.Account if in.o.DryRun { in.printf("would %s\n", desc) return nil } in.printf("%s\n", desc) cctx, cancel := context.WithTimeout(in.ctx, HostCheckBudget) defer cancel() if err := in.sys.CheckHost(cctx, in.acct, exe, in.o.Role, in.o.StateDir); err != nil { in.rep.Steps = append(in.rep.Steps, StepResult{Name: StepCheckHost, Outcome: OutcomeRefused, Detail: err.Error()}) if errors.Is(cctx.Err(), context.DeadlineExceeded) { return fmt.Errorf("install: %s did not finish within %s, and it prints what it found only at the end: %w", desc, HostCheckBudget, err) } return fmt.Errorf("install: %s: %w; fix what it names and re-run install, which completes from here", desc, err) } in.rep.Steps = append(in.rep.Steps, StepResult{Name: StepCheckHost, Outcome: OutcomeFound, Detail: "the host contract holds"}) return nil }