Stage 6: server image with a console installer (0.4.0)

- server/ profile and build-iso.sh [desktop|server]; make iso-server,
  make qemu-server, run-qemu.sh --serial; login on tty and ttyS0
- das-server: SSH, chrony, NetworkManager/dhcpcd, Docker with Compose,
  nftables (closed except SSH, own table), fail2ban reading socklog,
  sshd without root passwords, Docker log rotation, tools
- das-server-install: dialog installer in Ukrainian and English, or
  unattended with --config; GPT + Btrfs subvolumes, GRUB for UEFI and BIOS,
  serial console, SSH key with optional keys-only logins
- das-install-common: mount-source and postinstall shared with Calamares
- dsv: runit service manager (list, enable, disable, start, stop,
  restart, status, logs) with bash completion, also on the desktop
- das-update split into a subpackage without GTK; the window and tray
  icon move to /usr/libexec/das/das-update-gui
- das-snapper-setup enables dbus, which snapper needs
- TODO: ISO size review with measured numbers and options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-19 21:28:26 +03:00
co-authored by Claude Opus 5.5
parent 5349a028c8
commit eba4516998
42 changed files with 1454 additions and 114 deletions
@@ -0,0 +1,340 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"html"
"os"
"os/exec"
"strconv"
"strings"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
"dasos/tools/internal/i18n"
"dasos/tools/internal/system"
"dasos/tools/internal/update"
"dasos/tools/internal/xbps"
)
const (
appID = "ua.org.das.Update"
iconName = "software-update-available"
// pkexec exit codes when the password dialog was dismissed or failed
pkexecDismissed = 126
pkexecNotAuthed = 127
)
type window struct {
win *gtk.ApplicationWindow
status *gtk.Label
spinner *gtk.Spinner
store *gtk.ListStore
list *gtk.ScrolledWindow
info *gtk.Label
details *gtk.Expander
log *gtk.TextView
check *gtk.Button
install *gtk.Button
close *gtk.Button
running bool
snapshot int
}
func runWindow() int {
app, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE)
if err != nil {
fmt.Fprintln(os.Stderr, "das-update:", err)
return 1
}
var w *window
app.Connect("activate", func() {
// A second "das-update" only raises the open window.
if w != nil {
w.win.Present()
return
}
w, err = newWindow(app)
if err != nil {
fmt.Fprintln(os.Stderr, "das-update:", err)
app.Quit()
return
}
w.win.ShowAll()
w.details.Hide()
w.startCheck()
})
return app.Run(nil)
}
func newWindow(app *gtk.Application) (*window, error) {
w := &window{}
var err error
must := func(e error) {
if err == nil {
err = e
}
}
w.win, err = gtk.ApplicationWindowNew(app)
if err != nil {
return nil, err
}
w.win.SetTitle(i18n.T("update.title"))
w.win.SetIconName(iconName)
w.win.SetDefaultSize(620, 480)
w.win.SetPosition(gtk.WIN_POS_CENTER)
w.win.Connect("delete-event", func() bool { return w.running })
box, e := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 12)
must(e)
box.SetMarginStart(18)
box.SetMarginEnd(18)
box.SetMarginTop(18)
box.SetMarginBottom(18)
header, e := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 12)
must(e)
icon, e := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_DIALOG)
must(e)
w.status, e = gtk.LabelNew("")
must(e)
w.status.SetXAlign(0)
w.status.SetLineWrap(true)
w.spinner, e = gtk.SpinnerNew()
must(e)
header.PackStart(icon, false, false, 0)
header.PackStart(w.status, true, true, 0)
header.PackEnd(w.spinner, false, false, 0)
box.PackStart(header, false, false, 0)
w.store, e = gtk.ListStoreNew(glib.TYPE_STRING, glib.TYPE_STRING, glib.TYPE_STRING)
must(e)
tree, e := gtk.TreeViewNewWithModel(w.store)
must(e)
for i, title := range []string{"Пакет|Package", "Встановлено|Installed", "Нова версія|New version"} {
r, e := gtk.CellRendererTextNew()
must(e)
col, e := gtk.TreeViewColumnNewWithAttribute(pick(title), r, "text", i)
must(e)
col.SetResizable(true)
tree.AppendColumn(col)
}
w.list, e = gtk.ScrolledWindowNew(nil, nil)
must(e)
w.list.SetShadowType(gtk.SHADOW_IN)
w.list.SetVExpand(true)
w.list.Add(tree)
box.PackStart(w.list, true, true, 0)
w.info, e = gtk.LabelNew("")
must(e)
w.info.SetXAlign(0)
w.info.SetLineWrap(true)
w.info.SetSelectable(true)
box.PackStart(w.info, false, false, 0)
w.details, e = gtk.ExpanderNew(i18n.T("update.details"))
must(e)
w.log, e = gtk.TextViewNew()
must(e)
w.log.SetEditable(false)
w.log.SetCursorVisible(false)
w.log.SetMonospace(true)
w.log.SetWrapMode(gtk.WRAP_WORD_CHAR)
logScroll, e := gtk.ScrolledWindowNew(nil, nil)
must(e)
logScroll.SetShadowType(gtk.SHADOW_IN)
logScroll.SetSizeRequest(-1, 180)
logScroll.SetVExpand(true)
logScroll.Add(w.log)
w.details.Add(logScroll)
box.PackStart(w.details, true, true, 0)
buttons, e := gtk.ButtonBoxNew(gtk.ORIENTATION_HORIZONTAL)
must(e)
buttons.SetLayout(gtk.BUTTONBOX_END)
buttons.SetSpacing(6)
w.check, e = gtk.ButtonNewWithLabel(i18n.T("update.check"))
must(e)
w.install, e = gtk.ButtonNewWithLabel(i18n.T("update.install"))
must(e)
w.close, e = gtk.ButtonNewWithLabel(i18n.T("update.close"))
must(e)
if e == nil {
style, _ := w.install.GetStyleContext()
style.AddClass("suggested-action")
}
buttons.Add(w.check)
buttons.Add(w.install)
buttons.Add(w.close)
box.PackEnd(buttons, false, false, 0)
w.check.Connect("clicked", w.startCheck)
w.install.Connect("clicked", w.startInstall)
w.close.Connect("clicked", func() { w.win.Close() })
w.win.Add(box)
return w, err
}
// pick chooses from an inline "Ukrainian|English" pair.
func pick(pair string) string {
uk, en, _ := strings.Cut(pair, "|")
if strings.HasPrefix(system.Lang(), "uk") {
return uk
}
return en
}
func (w *window) setBusy(busy bool, status string) {
w.running = busy
w.status.SetMarkup("<b><big>" + html.EscapeString(status) + "</big></b>")
if busy {
w.spinner.Start()
} else {
w.spinner.Stop()
}
w.check.SetSensitive(!busy)
w.install.SetSensitive(!busy)
w.close.SetSensitive(!busy)
}
func (w *window) startCheck() {
w.setBusy(true, i18n.T("update.checking"))
w.info.SetText("")
go func() {
updates, err := xbps.Check(context.Background())
glib.IdleAdd(func() { w.showUpdates(updates, err) })
}()
}
func (w *window) showUpdates(updates []xbps.Update, err error) {
w.store.Clear()
switch {
case err != nil:
w.setBusy(false, i18n.T("update.checkfailed", err))
w.install.SetSensitive(false)
return
case len(updates) == 0:
w.setBusy(false, i18n.T("update.uptodate"))
w.install.SetSensitive(false)
w.list.Hide()
return
}
for _, u := range updates {
newVersion := u.NewVersion
if u.Action == "remove" {
newVersion = "−"
}
w.store.Set(w.store.Append(), []int{0, 1, 2}, []any{u.Name, u.OldVersion, newVersion})
}
w.details.Hide()
w.list.Show()
w.setBusy(false, i18n.Plural("update.count", len(updates)))
w.install.GrabFocus()
}
func (w *window) startInstall() {
w.setBusy(true, i18n.T("update.running"))
w.snapshot = 0
w.info.SetText("")
buf, _ := w.log.GetBuffer()
buf.SetText("")
// The log takes the place of the package list while xbps works.
w.list.Hide()
w.details.Show()
w.details.SetExpanded(true)
cmd := exec.Command("pkexec", "/usr/bin/das-update", "--apply", "--lang", system.Lang())
out, err := cmd.StdoutPipe()
if err == nil {
cmd.Stderr = cmd.Stdout
err = cmd.Start()
}
if err != nil {
w.finish(false, false, err.Error())
return
}
go func() {
var reboot, upToDate bool
var failure string
sc := bufio.NewScanner(out)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, update.MarkStep):
line = "\n==> " + strings.TrimPrefix(line, update.MarkStep)
case strings.HasPrefix(line, update.MarkSnapshot):
n, _ := strconv.Atoi(strings.TrimPrefix(line, update.MarkSnapshot))
glib.IdleAdd(func() { w.snapshot = n })
line = i18n.T("update.snapshot", n)
case line == update.MarkReboot:
reboot = true
continue
case line == update.MarkUpToDate:
upToDate = true
continue
case strings.HasPrefix(line, update.MarkError):
failure = strings.TrimPrefix(line, update.MarkError)
}
glib.IdleAdd(func() { w.appendLog(line + "\n") })
}
err := cmd.Wait()
var exit *exec.ExitError
if errors.As(err, &exit) && (exit.ExitCode() == pkexecDismissed || exit.ExitCode() == pkexecNotAuthed) && failure == "" {
glib.IdleAdd(func() { w.cancelled() })
return
}
if err != nil && failure == "" {
failure = err.Error()
}
glib.IdleAdd(func() {
if upToDate {
w.setBusy(false, i18n.T("update.uptodate"))
w.store.Clear()
w.install.SetSensitive(false)
return
}
w.finish(failure == "", reboot, failure)
})
}()
}
func (w *window) appendLog(text string) {
buf, _ := w.log.GetBuffer()
buf.Insert(buf.GetEndIter(), text)
buf.PlaceCursor(buf.GetEndIter())
w.log.ScrollToMark(buf.GetInsert(), 0, false, 0, 0)
}
func (w *window) cancelled() {
w.setBusy(false, i18n.T("update.cancelled"))
w.details.Hide()
w.list.Show()
}
func (w *window) finish(ok, reboot bool, failure string) {
var info []string
if ok {
w.setBusy(false, i18n.T("update.done"))
w.store.Clear()
w.install.SetSensitive(false)
if reboot {
info = append(info, i18n.T("update.reboot"))
}
} else {
w.setBusy(false, i18n.T("update.failed"))
w.details.SetExpanded(true)
if failure != "" {
w.appendLog("\n" + failure + "\n")
}
}
if w.snapshot > 0 {
info = append(info, i18n.T("update.snapshot", w.snapshot), i18n.T("update.rollback", w.snapshot, w.snapshot))
}
w.info.SetText(strings.Join(info, "\n"))
}