package main import ( "context" "encoding/json" "errors" "fmt" "os" "os/user" "path/filepath" "slices" "strings" "time" "github.com/johnandersen777/socialweb-computer-kcp/internal/app/compose" "github.com/johnandersen777/socialweb-computer-kcp/internal/app/marketrequester" "github.com/johnandersen777/socialweb-computer-kcp/internal/buildstamp" "github.com/johnandersen777/socialweb-computer-kcp/internal/clicfg" "github.com/johnandersen777/socialweb-computer-kcp/internal/foldmain" "github.com/johnandersen777/socialweb-computer-kcp/internal/hostcontract" "github.com/johnandersen777/socialweb-computer-kcp/internal/install" "github.com/johnandersen777/socialweb-computer-kcp/internal/stack/request" ) var installOptions = []string{backendOpt, "handle", "account", "state-dir", "vm-slots", "dry-run", "adopt-account", "json", "root", "unit-prefix", "allow-dev-binary", install.OptKcpFront, install.OptTLSFront, install.OptCertFront, install.OptSpecFile, install.OptGuestImg, install.OptGuestImgBase, install.OptReadyTimeout, install.OptNetworkTimeout, install.OptNoGuestLANUDP, install.OptGuestHostUDP, install.OptBidderPolicy, install.OptBidderOperator, install.OptComputeKinds} var installRequesterOptions = []string{backendOpt, "name", "account", "state-dir", "dry-run", "adopt-account", "json", "root", "unit-prefix", "allow-dev-binary", install.OptReadyTimeout, install.OptNetworkTimeout} var uninstallOptions = []string{"account", "purge", "force", "json", "root"} func splitHandles(s string) []string { var out []string for h := range strings.SplitSeq(s, ",") { if h = strings.TrimSpace(h); h != "" { out = append(out, h) } } return out } func installExit(a *app, verb string, rep install.Report, err error, asJSON bool) int { if asJSON { a.printJSON(rep) } else { printSteps(a, rep) } if err == nil { return exitOK } fmt.Fprintf(a.stderr, "%s: %v\n", verb, err) switch { case errors.Is(err, install.ErrProcBackend), errors.Is(err, install.ErrForeignAccount), errors.Is(err, install.ErrVMsRunning), errors.Is(err, install.ErrManagerWithoutKVM), errors.Is(err, install.ErrNoRecord), errors.Is(err, install.ErrUnsupported), errors.Is(err, install.ErrDevBinary), errors.Is(err, install.ErrBinaryMismatch), errors.Is(err, install.ErrUsage): return exitRefused } return exitFailed } func printSteps(a *app, rep install.Report) { if len(rep.Steps) == 0 { return } for _, s := range rep.Steps { fmt.Fprintf(a.stdout, "%-15s %-20s %s\n", strings.ToUpper(s.Outcome), s.Name, s.Detail) } } func runInstall(role hostcontract.Role) verbFunc { verb := "install " + string(role) return func(ctx context.Context, a *app, vals clicfg.Values, rest []string) int { var names []string var records map[string][]byte if role == hostcontract.RoleRequester { var err error if names, records, err = requesterInstallRecords(vals, rest); err != nil { return a.fail(exitRefused, "%s: %v", verb, err) } } else if len(rest) > 0 { return a.fail(exitRefused, "%s: unexpected arguments %q", verb, rest) } if vals.String(backendOpt) == "proc" { return installExit(a, verb, install.Report{}, install.ErrProcBackend, false) } dry := vals.Bool("dry-run") root := vals.String("root") staged := install.Rooted(root) if os.Geteuid() != 0 && !dry && !staged { return a.fail(exitRefused, "%s: the one explicit root step: run it as root (or --dry-run to print the step list, or --root for a staged host)", verb) } if staged && os.Geteuid() == 0 { return a.fail(exitRefused, "%s: --root stages a host for the invoking user's own manager; run it as that user, never as root", verb) } if stamp := buildstamp.Read(); stamp != nil && !vals.Bool("allow-dev-binary") { return installExit(a, verb, install.Report{}, fmt.Errorf("%w: this binary (commit %s, built %s)", install.ErrDevBinary, stamp.Commit, stamp.BuiltUTC), false) } exe, err := foldmain.ResolveExe() if err != nil { return a.fail(exitFailed, "%s: resolving this binary: %v", verb, err) } sys, err := a.installSystem(ctx, root) if err != nil { return installExit(a, verb, install.Report{}, err, false) } listen := map[string]string{} passed := func(k string) string { if s := vals.Source(k); s == clicfg.SourceFlag || s == clicfg.SourceEnv { return vals.String(k) } return "" } for _, k := range install.Passed { if role == hostcontract.RoleRequester { continue } if v := passed(k); v != "" { listen[k] = v } } rep, err := install.Install(ctx, install.Options{ Role: role, Handles: installHandles(role, vals), Names: names, RequesterRecords: records, Account: installAccount(vals, staged), StateDir: vals.String("state-dir"), Binary: exe, VMSlots: installVMSlots(role, vals), DryRun: dry, AdoptAccount: vals.Bool("adopt-account"), HostBackend: vals.String(backendOpt), Out: a.stdout, Root: root, Staged: staged, UnitPrefix: vals.String("unit-prefix"), Settings: listen, ReadyTimeout: passed(install.OptReadyTimeout), NetworkTimeout: passed(install.OptNetworkTimeout), GuestImageBase: installGuestImageBase(role, vals), GuestImageBaseResolved: true, }, sys) return installExit(a, verb, rep, err, vals.Bool("json")) } } func (a *app) installSystem(ctx context.Context, root string) (install.System, error) { if install.Rooted(root) { if !filepath.IsAbs(root) { return nil, fmt.Errorf("--root %q is not absolute", root) } return install.NewStagedSystem(root, a.stdout) } return install.NewSystem(ctx, a.stdout) } func runUninstall(role hostcontract.Role) verbFunc { verb := "uninstall " + string(role) return func(ctx context.Context, a *app, vals clicfg.Values, rest []string) int { if len(rest) > 0 { return a.fail(exitRefused, "%s: unexpected arguments %q", verb, rest) } root := vals.String("root") staged := install.Rooted(root) if os.Geteuid() != 0 && !staged { return a.fail(exitRefused, "%s: run it as root", verb) } sys, err := a.installSystem(ctx, root) if err != nil { return installExit(a, verb, install.Report{}, err, false) } rep, err := install.Uninstall(ctx, install.UninstallOptions{Role: role, Account: installAccount(vals, staged), Purge: vals.Bool("purge"), Force: vals.Bool("force"), Root: root, Staged: staged, Out: a.stdout}, sys) return installExit(a, verb, rep, err, vals.Bool("json")) } } var upgradeOptions = []string{"binary", "sha256", "state-dir", "allow-dev-binary", "root"} func installedBinaries(state string) []string { return []string{filepath.Join(state, "bin", "current", install.CoreName), filepath.Join(state, "bin", "previous", install.CoreName)} } func (a *app) upgradeOptions(ctx context.Context, verb string, vals clicfg.Values) (install.UpgradeOptions, error) { if os.Geteuid() == 0 { return install.UpgradeOptions{}, fmt.Errorf("%s runs as the installed account, never as root", verb) } state := vals.String("state-dir") if state == "" { return install.UpgradeOptions{}, fmt.Errorf("%s: --state-dir (SOCIALWEB_STATE_DIR) names the account's state dir; it is read from that flag or env var, never from a config.json", verb) } if !filepath.IsAbs(state) { return install.UpgradeOptions{}, fmt.Errorf("%s: state-dir %q is not absolute", verb, state) } u, err := user.Current() if err != nil { return install.UpgradeOptions{}, fmt.Errorf("%s: the invoking account: %w", verb, err) } home, root := u.HomeDir, vals.String("root") if install.Rooted(root) { sys, err := a.installSystem(ctx, root) if err != nil { return install.UpgradeOptions{}, err } acct, ok, err := sys.LookupAccount(u.Username) if err != nil { return install.UpgradeOptions{}, err } if !ok { return install.UpgradeOptions{}, fmt.Errorf("%s: %s is no account of the staged host %s", verb, u.Username, root) } home = acct.Home } return install.UpgradeOptions{StateDir: state, Home: home, Account: u.Username, Root: root, RuntimeDir: install.OwnRuntimeDir(), Out: a.stdout, SelfCheck: install.SelfCheck, Restart: install.RestartOwnUnits}, nil } func runUpgrade(ctx context.Context, a *app, vals clicfg.Values, rest []string) int { o, err := a.upgradeOptions(ctx, "upgrade", vals) if err != nil { return a.fail(exitRefused, "%v", err) } o.Binary, o.SHA256, o.AllowDev, o.DevStamp = vals.String("binary"), vals.String("sha256"), vals.Bool("allow-dev-binary"), install.DevStamp if o.Binary == "" { return a.fail(exitRefused, "upgrade: --binary names the new socialweb-computer executable") } if err := install.Upgrade(ctx, o); err != nil { code := exitFailed if errors.Is(err, install.ErrManifestMismatch) || errors.Is(err, install.ErrSelfCheck) || errors.Is(err, install.ErrDevBinary) { code = exitRefused } return a.fail(code, "%v", err) } return exitOK } func runRollback(ctx context.Context, a *app, vals clicfg.Values, rest []string) int { o, err := a.upgradeOptions(ctx, "rollback", vals) if err != nil { return a.fail(exitRefused, "%v", err) } if err := install.Rollback(ctx, o); err != nil { code := exitFailed if errors.Is(err, install.ErrNoPrevious) { code = exitRefused } return a.fail(code, "%v", err) } return exitOK } func installAccount(vals clicfg.Values, staged bool) string { if a := vals.String("account"); a != "" || !staged { return a } if u, err := user.Current(); err == nil { return u.Username } return "" } func installHandles(role hostcontract.Role, vals clicfg.Values) []string { if role == hostcontract.RoleRequester { return nil } return splitHandles(vals.String("handle")) } func installVMSlots(role hostcontract.Role, vals clicfg.Values) int { if role == hostcontract.RoleRequester { return 0 } return vals.Int("vm-slots") } func installGuestImageBase(role hostcontract.Role, vals clicfg.Values) string { if role == hostcontract.RoleRequester { return "" } return vals.String(install.OptGuestImgBase) } func requesterInstallRecords(vals clicfg.Values, rest []string) ([]string, map[string][]byte, error) { pos, pass := splitWords(rest) if len(pos) > 0 { return nil, nil, fmt.Errorf("unexpected %q; MarketRequester options go after --", strings.Join(pos, " ")) } parsed, err := compose.ParseAppArgs(marketrequester.RequesterApp, pass) if err != nil { return nil, nil, err } if !slices.ContainsFunc(parsed, func(p compose.AppArg) bool { return p.To == "spec.identity.mode" }) { pass = append([]string{"--atproto-oauth-qr"}, pass...) } for _, p := range parsed { if p.Name == "atproto-password" { return nil, nil, errors.New("--atproto-password: an installed requester never takes a password as an option; after install, run as the account: socialweb-computer run requester password < ") } } spec, err := parseRequesterSpec(pass, "install requester", installRequesterOptions, false, nil) if err != nil { return nil, nil, err } names := splitHandles(vals.String("name")) records := map[string][]byte{} for _, n := range names { if err := request.CheckName(n); err != nil { return nil, nil, err } b, err := json.MarshalIndent(request.RequesterRecord{Schema: request.RequesterSchema, Name: n, Spec: spec, CreatedAt: time.Now().UTC().Truncate(time.Second)}, "", " ") if err != nil { return nil, nil, err } records[n] = append(b, '\n') } return names, records, nil }