19 Commits

Author SHA1 Message Date
fa9bc70b39 cmd/pkgserver: do not assume default mux
This helps with testing.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-11 00:06:12 +09:00
4a63fbbc2a cmd/pkgserver: create index without report
This is useful for testing, where report testdata is not available.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-10 22:31:59 +09:00
mae
b104ad6e2d cmd/pkgserver: add sort orders, change pagination rules 2026-03-10 05:18:55 -05:00
mae
469bd1ee99 cmd/pkgserver: add /status endpoint 2026-03-10 04:24:49 -05:00
mae
52a4e5b87d cmd/pkgserver: minimum viable frontend 2026-03-10 03:32:17 -05:00
mae
35d76c5d2b cmd/pkgserver: api versioning 2026-03-10 17:28:49 +09:00
mae
dfd3301a33 cmd/pkgserver: add get endpoint 2026-03-09 18:18:51 -05:00
mae
a4ce41ea9a cmd/pkgserver: add count endpoint and restructure 2026-03-09 15:41:21 -05:00
mae
773e43a215 cmd/pkgserver: add status endpoint 2026-03-09 04:09:18 -05:00
mae
f150e1fdd6 cmd/pkgserver: add createPackageIndex 2026-03-09 01:27:46 -05:00
mae
dec7010c35 cmd/pkgserver: add command handler 2026-03-08 22:28:08 -05:00
mae
69bd88282c cmd/pkgserver: replace favicon 2026-03-05 01:12:17 -06:00
mae
ca2053d3ba cmd/pkgserver: pagination 2026-03-05 00:32:25 -06:00
mae
8d0aa1127c cmd/pkgserver: basic web ui 2026-03-04 22:50:58 -06:00
48cdf8bf85 go: 1.26
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-10 03:29:19 +09:00
7fb42ba49d internal/rosa/llvm: set LLVM_LIT_ARGS
This replaces the progress bar, which was worse than useless.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-10 02:05:11 +09:00
19a2737148 container: sched policy string representation
This also uses priority obtained via sched_get_priority_min, and improves bounds checking.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-09 18:38:31 +09:00
baf2def9cc internal/rosa/kmod: prefix moduledir
This change also works around the kernel build system being unaware of this option.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-09 16:40:55 +09:00
242e042cb9 internal/rosa/nss: rename from ssl
The SSL name came from earlier on and is counterintuitive.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-09 14:58:31 +09:00
25 changed files with 808 additions and 390 deletions

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ go.work.sum
# go generate
/cmd/hakurei/LICENSE
/cmd/pkgserver/.sass-cache
/internal/pkg/testdata/testtool
/internal/rosa/hakurei_current.tar.gz

144
cmd/pkgserver/api.go Normal file
View File

@@ -0,0 +1,144 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strconv"
"hakurei.app/internal/info"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
)
type InfoPayload struct {
Count int `json:"count"`
HakureiVersion string `json:"hakurei_version"`
}
func NewInfoPayload(index *PackageIndex) InfoPayload {
count := len(index.sorts[0])
return InfoPayload{
Count: count,
HakureiVersion: info.Version(),
}
}
func serveInfo(index *PackageIndex) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "text/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
WritePayload(w, NewInfoPayload(index))
}
}
func serveStatus(index *PackageIndex) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
download := path.Dir(r.URL.Path) == "/status"
if index == nil {
http.Error(w, "index is nil", http.StatusInternalServerError)
return
}
name := path.Base(r.URL.Path)
p, ok := rosa.ResolveName(name)
if !ok {
http.NotFound(w, r)
return
}
m := rosa.GetMetadata(p)
pk, ok := index.names[m.Name]
if !ok {
http.NotFound(w, r)
return
}
if len(pk.status) > 0 {
if download {
w.Header().Set("Content-Type", "application/octet-stream")
} else {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
if download {
var version string
if pk.Version != "\u0000" {
version = pk.Version
} else {
version = "unknown"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s-%s.log\"", pk.Name, version, pkg.Encode(pk.ident.Value())))
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusOK)
_, err := io.Copy(w, bytes.NewReader(pk.status))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else {
http.NotFound(w, r)
}
}
}
type GetPayload struct {
Count int `json:"count"`
Values []PackageIndexEntry `json:"values"`
}
func NewGetPayload(values []*PackageIndexEntry) GetPayload {
count := len(values)
v := make([]PackageIndexEntry, count)
for i, _ := range values {
v[i] = *values[i]
}
return GetPayload{
Count: count,
Values: v,
}
}
func serveGet(index *PackageIndex) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit > 100 || limit < 1 {
http.Error(w, fmt.Sprintf("limit must be an integer between 1 and 100"), http.StatusBadRequest)
return
}
i, err := strconv.Atoi(q.Get("index"))
if err != nil || i >= len(index.sorts[0]) || i < 0 {
http.Error(w, fmt.Sprintf("index must be an integer between 0 and %d", len(index.sorts[0])-1), http.StatusBadRequest)
return
}
sort, err := strconv.Atoi(q.Get("sort"))
if err != nil || sort >= len(index.sorts) || sort < 0 {
http.Error(w, fmt.Sprintf("sort must be an integer between 0 and %d", len(index.sorts)-1), http.StatusBadRequest)
return
}
values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))]
WritePayload(w, NewGetPayload(values))
}
}
const ApiVersion = "v1"
func apiRoutes(mux *http.ServeMux, index *PackageIndex) {
mux.HandleFunc(fmt.Sprintf("GET /api/%s/info", ApiVersion), serveInfo(index))
mux.HandleFunc(fmt.Sprintf("GET /api/%s/get", ApiVersion), serveGet(index))
mux.HandleFunc(fmt.Sprintf("GET /api/%s/status/", ApiVersion), serveStatus(index))
mux.HandleFunc("GET /status/", serveStatus(index))
}
func WritePayload(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
err := json.NewEncoder(w).Encode(payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

86
cmd/pkgserver/index.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"cmp"
"slices"
"unique"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
)
type SortOrders int
const (
DeclarationAscending SortOrders = iota
DeclarationDescending
NameAscending
NameDescending
limitSortOrders
)
type PackageIndex struct {
sorts [limitSortOrders][rosa.PresetUnexportedStart]*PackageIndexEntry
names map[string]*PackageIndexEntry
}
type PackageIndexEntry struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Website string `json:"website,omitempty"`
Version string `json:"version"`
HasReport bool `json:"report,omitempty"`
ident unique.Handle[pkg.ID] `json:"-"`
status []byte `json:"-"`
}
func createPackageIndex(cache *pkg.Cache, report *rosa.Report) (index *PackageIndex, err error) {
index = new(PackageIndex)
index.names = make(map[string]*PackageIndexEntry, rosa.PresetUnexportedStart)
work := make([]PackageIndexEntry, rosa.PresetUnexportedStart)
if report != nil {
defer report.HandleAccess(&err)()
}
for p := range rosa.PresetUnexportedStart {
m := rosa.GetMetadata(p)
v := rosa.Std.Version(p)
a := rosa.Std.Load(p)
entry := PackageIndexEntry{
Name: m.Name,
Description: m.Description,
Website: m.Website,
Version: v,
}
if cache != nil && report != nil {
entry.ident = cache.Ident(a)
status, n := report.ArtifactOf(entry.ident)
if n >= 0 {
entry.HasReport = true
entry.status = status
}
}
work[p] = entry
index.names[m.Name] = &entry
}
for i, p := range work {
index.sorts[DeclarationAscending][i] = &p
}
slices.Reverse(work)
for i, p := range work {
index.sorts[DeclarationDescending][i] = &p
}
slices.SortFunc(work, func(a PackageIndexEntry, b PackageIndexEntry) int {
return cmp.Compare(a.Name, b.Name)
})
for i, p := range work {
index.sorts[NameAscending][i] = &p
}
slices.Reverse(work)
for i, p := range work {
index.sorts[NameDescending][i] = &p
}
return
}

View File

@@ -1,19 +1,12 @@
package main
import (
"bytes"
"cmp"
"context"
"embed"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"path"
"slices"
"strings"
"syscall"
"hakurei.app/command"
@@ -23,150 +16,7 @@ import (
"hakurei.app/message"
)
//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && tsc ui/static/index.ts"
//go:embed ui/*
var content embed.FS
func serveWebUI(w http.ResponseWriter, r *http.Request) {
fmt.Printf("serveWebUI: %s\n", r.URL.Path)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("X-Frame-Options", "DENY")
http.ServeFileFS(w, r, content, "ui/index.html")
}
func serveStaticContent(w http.ResponseWriter, r *http.Request) {
fmt.Printf("serveStaticContent: %s\n", r.URL.Path)
switch r.URL.Path {
case "/static/style.css":
darkTheme := r.CookiesNamed("dark_theme")
if len(darkTheme) > 0 && darkTheme[0].Value == "true" {
http.ServeFileFS(w, r, content, "ui/static/dark.css")
} else {
http.ServeFileFS(w, r, content, "ui/static/light.css")
}
break
case "/favicon.ico":
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
break
case "/static/index.js":
http.ServeFileFS(w, r, content, "ui/static/index.js")
break
default:
http.NotFound(w, r)
}
}
func serveAPI(index *PackageIndex) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {}
}
func serveStatus(index *PackageIndex) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if index == nil {
http.Error(w, "index is nil", http.StatusInternalServerError)
return
}
base := path.Base(r.URL.Path)
name := strings.TrimSuffix(base, ".log")
p, ok := rosa.ResolveName(name)
if !ok {
http.NotFound(w, r)
return
}
m := rosa.GetMetadata(p)
pk, ok := index.names[m.Name]
if !ok {
http.NotFound(w, r)
return
}
if len(pk.status) > 0 {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusOK)
_, err := io.Copy(w, bytes.NewReader(pk.status))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else {
http.NotFound(w, r)
}
}
}
type SortOrders int
const (
DeclarationAscending SortOrders = iota
DeclarationDescending
NameAscending
NameDescending
limitSortOrders
)
type PackageIndex struct {
sorts [limitSortOrders][rosa.PresetUnexportedStart]*PackageIndexEntry
names map[string]*PackageIndexEntry
}
type PackageIndexEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Website string `json:"website"`
Version string `json:"version"`
status []byte
}
func createPackageIndex(cache *pkg.Cache, report *rosa.Report) (_ *PackageIndex, err error) {
index := new(PackageIndex)
index.names = make(map[string]*PackageIndexEntry, rosa.PresetUnexportedStart)
work := make([]PackageIndexEntry, rosa.PresetUnexportedStart)
defer report.HandleAccess(&err)()
for p := range rosa.PresetUnexportedStart {
m := rosa.GetMetadata(p)
v := rosa.Std.Version(p)
a := rosa.Std.Load(p)
id := cache.Ident(a)
st, n := report.ArtifactOf(id)
var status []byte
if n < 1 {
status = nil
} else {
status = st
}
log.Printf("Processing package %s...\n", m.Name)
entry := PackageIndexEntry{
Name: m.Name,
Description: m.Description,
Website: m.Website,
Version: v,
status: status,
}
work[p] = entry
index.names[m.Name] = &entry
}
for i, p := range work {
index.sorts[DeclarationAscending][i] = &p
}
slices.Reverse(work)
for i, p := range work {
index.sorts[DeclarationDescending][i] = &p
}
slices.SortFunc(work, func(a PackageIndexEntry, b PackageIndexEntry) int {
return cmp.Compare(a.Name, b.Name)
})
for i, p := range work {
index.sorts[NameAscending][i] = &p
}
slices.Reverse(work)
for i, p := range work {
index.sorts[NameDescending][i] = &p
}
return index, err
}
//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && tsc -p ui/static"
func main() {
log.SetFlags(0)
log.SetPrefix("pkgserver: ")
@@ -186,8 +36,8 @@ func main() {
if err != nil {
return err
}
log.Println("baseDir:", baseDir)
cache, err := pkg.Open(ctx, msg, 0, baseDir)
defer cache.Close()
if err != nil {
return err
}
@@ -195,19 +45,12 @@ func main() {
if err != nil {
return err
}
log.Println("reportPath:", reportPath)
log.Println("indexing packages...")
index, err := createPackageIndex(cache, report)
if err != nil {
return err
}
log.Println("created package index")
http.HandleFunc("GET /{$}", serveWebUI)
http.HandleFunc("GET /favicon.ico", serveStaticContent)
http.HandleFunc("GET /static/", serveStaticContent)
http.HandleFunc("GET /api/", serveAPI(index))
http.HandleFunc("GET /api/status/", serveStatus(index))
log.Println("listening on", flagPort)
uiRoutes(http.DefaultServeMux)
apiRoutes(http.DefaultServeMux, index)
err = http.ListenAndServe(fmt.Sprintf(":%d", flagPort), nil)
if err != nil {
return err

47
cmd/pkgserver/ui.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"embed"
"net/http"
)
//go:embed ui/*
var content embed.FS
func serveWebUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("X-Frame-Options", "DENY")
http.ServeFileFS(w, r, content, "ui/index.html")
}
func serveStaticContent(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/static/style.css":
darkTheme := r.CookiesNamed("dark_theme")
if len(darkTheme) > 0 && darkTheme[0].Value == "true" {
http.ServeFileFS(w, r, content, "ui/static/dark.css")
} else {
http.ServeFileFS(w, r, content, "ui/static/light.css")
}
break
case "/favicon.ico":
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
break
case "/static/index.js":
http.ServeFileFS(w, r, content, "ui/static/index.js")
break
default:
http.NotFound(w, r)
}
}
func uiRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", serveWebUI)
mux.HandleFunc("GET /favicon.ico", serveStaticContent)
mux.HandleFunc("GET /static/", serveStaticContent)
}

View File

@@ -4,23 +4,30 @@
<meta charset="UTF-8">
<link rel="stylesheet" href="static/style.css">
<title>Hakurei PkgServer</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="static/index.js"></script>
</head>
<body>
<h1>Hakurei PkgServer</h1>
<table id="pkg-list">
<tr><th>Status</th><th>Name</th><th>Version</th></tr>
<tr><td>Loading...</td></tr>
</table>
<p>Showing entries <span id="entry-counter"></span>.</p>
<span class="bottom-nav"><a href="javascript:prevPage()">&laquo; Previous</a> <span id="page-number">1</span> <a href="javascript:nextPage()">Next &raquo;</a></span>
<span><label for="count">Entries per page:</label><select name="count" id="count">
<span><label for="count">Entries per page: </label><select name="count" id="count">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select></span>
<span><label for="sort">Sort by: </label><select name="sort" id="sort">
<option value="0">Definition (ascending)</option>
<option value="1">Definition (descending)</option>
<option value="2">Name (ascending)</option>
<option value="3">Name (descending)</option>
</select></span>
</body>
<footer>&copy; <a href="https://hakurei.app/">Hakurei</a>. Licensed under the MIT license.</footer>
<footer>
<p>&copy;<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
</footer>
</html>

View File

@@ -1,67 +1,134 @@
"use strict";
var PackageEntry = /** @class */ (function () {
function PackageEntry() {
}
return PackageEntry;
}());
var State = /** @class */ (function () {
function State() {
this.entriesPerPage = 10;
this.currentPage = 1;
this.entryIndex = 0;
this.loadedEntries = [];
}
State.prototype.getEntriesPerPage = function () {
class PackageIndexEntry {
name;
description;
website;
version;
report;
}
function toHTML(entry) {
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : "";
let d = entry.description != null ? `<p>${escapeHtml(entry.description)}</p>` : "";
let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : "";
let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : "";
let row = (document.createElement('tr'));
row.innerHTML = `<td>
<h2>${escapeHtml(entry.name)} ${v}</h2>
${d}
${w}
${r}
</td>`;
return row;
}
const API_VERSION = 1;
const ENDPOINT = `/api/v${API_VERSION}`;
class InfoPayload {
count;
hakurei_version;
}
async function infoRequest() {
const res = await fetch(`${ENDPOINT}/info`);
const payload = await res.json();
return payload;
}
class GetPayload {
count;
values;
}
var SortOrders;
(function (SortOrders) {
SortOrders[SortOrders["DeclarationAscending"] = 0] = "DeclarationAscending";
SortOrders[SortOrders["DeclarationDescending"] = 1] = "DeclarationDescending";
SortOrders[SortOrders["NameAscending"] = 2] = "NameAscending";
SortOrders[SortOrders["NameDescending"] = 3] = "NameDescending";
})(SortOrders || (SortOrders = {}));
async function getRequest(limit, index, sort) {
const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`);
const payload = await res.json();
return payload;
}
class State {
entriesPerPage = 10;
entryIndex = 0;
maxEntries = 0;
sort = SortOrders.DeclarationAscending;
getEntriesPerPage() {
return this.entriesPerPage;
};
State.prototype.setEntriesPerPage = function (entriesPerPage) {
this.entriesPerPage = entriesPerPage;
this.updateRange();
};
State.prototype.getCurrentPage = function () {
return this.currentPage;
};
State.prototype.setCurrentPage = function (page) {
this.currentPage = page;
document.getElementById("page-number").innerText = String(this.currentPage);
this.updateRange();
};
State.prototype.getEntryIndex = function () {
return this.entryIndex;
};
State.prototype.setEntryIndex = function (entryIndex) {
this.entryIndex = entryIndex;
this.updateRange();
};
State.prototype.getLoadedEntries = function () {
return this.loadedEntries;
};
State.prototype.getMaxPage = function () {
return this.loadedEntries.length / this.entriesPerPage;
};
State.prototype.updateRange = function () {
var max = Math.min(this.entryIndex + this.entriesPerPage, this.loadedEntries.length);
document.getElementById("entry-counter").innerText = "".concat(this.entryIndex, "-").concat(max, " of ").concat(this.loadedEntries.length);
};
return State;
}());
var STATE;
function prevPage() {
var current = STATE.getCurrentPage();
if (current > 1) {
STATE.setCurrentPage(STATE.getCurrentPage() - 1);
}
setEntriesPerPage(entriesPerPage) {
this.entriesPerPage = entriesPerPage;
this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage);
}
getEntryIndex() {
return this.entryIndex;
}
setEntryIndex(entryIndex) {
this.entryIndex = entryIndex;
this.updatePage();
this.updateRange();
this.updateListings();
}
getMaxEntries() {
return this.maxEntries;
}
setMaxEntries(max) {
this.maxEntries = max;
}
getSortOrder() {
return this.sort;
}
setSortOrder(sortOrder) {
this.sort = sortOrder;
this.setEntryIndex(0);
}
updatePage() {
let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage());
document.getElementById("page-number").innerText = String(page);
}
updateRange() {
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries());
document.getElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}`;
}
updateListings() {
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
.then(res => {
let table = document.getElementById("pkg-list");
table.innerHTML = '';
for (let i = 0; i < res.count; i++) {
table.appendChild(toHTML(res.values[i]));
}
});
}
}
let STATE;
function prevPage() {
let index = STATE.getEntryIndex();
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()));
}
function nextPage() {
var current = STATE.getCurrentPage();
if (current < STATE.getMaxPage()) {
STATE.setCurrentPage(STATE.getCurrentPage() + 1);
}
let index = STATE.getEntryIndex();
STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage()));
}
document.addEventListener("DOMContentLoaded", function () {
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
document.addEventListener("DOMContentLoaded", () => {
STATE = new State();
infoRequest()
.then(res => {
STATE.setMaxEntries(res.count);
document.getElementById("hakurei-version").innerText = res.hakurei_version;
STATE.updateRange();
document.getElementById("count").addEventListener("change", function (event) {
STATE.updateListings();
});
document.getElementById("count").addEventListener("change", (event) => {
STATE.setEntriesPerPage(parseInt(event.target.value));
});
document.getElementById("sort").addEventListener("change", (event) => {
STATE.setSortOrder(parseInt(event.target.value));
});
});

View File

@@ -1,66 +1,143 @@
"use strict"
class PackageIndexEntry {
name: string
description: string | null
website: string | null
version: string | null
report: boolean
}
function toHTML(entry: PackageIndexEntry): HTMLTableRowElement {
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
let d = entry.description != null ? `<p>${escapeHtml(entry.description)}</p>` : ""
let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : ""
let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : ""
let row = <HTMLTableRowElement>(document.createElement('tr'))
row.innerHTML = `<td>
<h2>${escapeHtml(entry.name)} ${v}</h2>
${d}
${w}
${r}
</td>`
return row
}
class PackageEntry {
const API_VERSION = 1
const ENDPOINT = `/api/v${API_VERSION}`
class InfoPayload {
count: number
hakurei_version: string
}
async function infoRequest(): Promise<InfoPayload> {
const res = await fetch(`${ENDPOINT}/info`)
const payload = await res.json()
return payload as InfoPayload
}
class GetPayload {
count: number
values: PackageIndexEntry[]
}
enum SortOrders {
DeclarationAscending = 0,
DeclarationDescending,
NameAscending,
NameDescending
}
async function getRequest(limit: number, index: number, sort: SortOrders): Promise<GetPayload> {
const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`)
const payload = await res.json()
return payload as GetPayload
}
class State {
entriesPerPage: number = 10
currentPage: number = 1
entryIndex: number = 0
loadedEntries: PackageEntry[] = []
maxEntries: number = 0
sort: SortOrders = SortOrders.DeclarationAscending
getEntriesPerPage(): number {
return this.entriesPerPage
}
setEntriesPerPage(entriesPerPage: number) {
this.entriesPerPage = entriesPerPage
this.updateRange()
}
getCurrentPage(): number {
return this.currentPage
}
setCurrentPage(page: number) {
this.currentPage = page
document.getElementById("page-number").innerText = String(this.currentPage)
this.updateRange()
this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage)
}
getEntryIndex(): number {
return this.entryIndex
}
setEntryIndex(entryIndex: number) {
this.entryIndex = entryIndex
this.updatePage()
this.updateRange()
this.updateListings()
}
getLoadedEntries(): PackageEntry[] {
return this.loadedEntries
getMaxEntries(): number {
return this.maxEntries
}
getMaxPage(): number {
return this.loadedEntries.length / this.entriesPerPage
setMaxEntries(max: number) {
this.maxEntries = max
}
getSortOrder(): SortOrders {
return this.sort
}
setSortOrder(sortOrder: SortOrders) {
this.sort = sortOrder
this.setEntryIndex(0)
}
updatePage() {
let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage())
document.getElementById("page-number").innerText = String(page)
}
updateRange() {
let max = Math.min(this.entryIndex + this.entriesPerPage, this.loadedEntries.length)
document.getElementById("entry-counter").innerText = `${this.entryIndex}-${max} of ${this.loadedEntries.length}`
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries())
document.getElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}`
}
updateListings() {
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
.then(res => {
let table = document.getElementById("pkg-list")
table.innerHTML = ''
for(let i = 0; i < res.count; i++) {
table.appendChild(toHTML(res.values[i]))
}
})
}
}
let STATE: State
function prevPage() {
let current = STATE.getCurrentPage()
if (current > 1) {
STATE.setCurrentPage(STATE.getCurrentPage() - 1)
}
let index = STATE.getEntryIndex()
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
}
function nextPage() {
let current = STATE.getCurrentPage()
if (current < STATE.getMaxPage()) {
STATE.setCurrentPage(STATE.getCurrentPage() + 1)
}
let index = STATE.getEntryIndex()
STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage()))
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
document.addEventListener("DOMContentLoaded", () => {
STATE = new State()
infoRequest()
.then(res => {
STATE.setMaxEntries(res.count)
document.getElementById("hakurei-version").innerText = res.hakurei_version
STATE.updateRange()
STATE.updateListings()
})
document.getElementById("count").addEventListener("change", (event) => {
STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
})
document.getElementById("sort").addEventListener("change", (event) => {
STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
})
})

View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"target": "ES2024"
}
}

View File

@@ -40,7 +40,7 @@ type (
AllowOrphan bool
// Scheduling policy to set via sched_setscheduler(2). The zero value
// skips this call. Supported policies are [SCHED_BATCH], [SCHED_IDLE].
SchedPolicy int
SchedPolicy SchedPolicy
// Cgroup fd, nil to disable.
Cgroup *int
// ExtraFiles passed through to initial process in the container, with
@@ -373,12 +373,23 @@ func (p *Container) Start() error {
// sched_setscheduler: thread-directed but acts on all processes
// created from the calling thread
if p.SchedPolicy > 0 {
if p.SchedPolicy > 0 && p.SchedPolicy <= _SCHED_LAST {
var param schedParam
if priority, err := p.SchedPolicy.GetPriorityMin(); err != nil {
return &StartError{
Fatal: true,
Step: "get minimum priority",
Err: err,
}
} else {
param.priority = priority
}
p.msg.Verbosef("setting scheduling policy %d", p.SchedPolicy)
if err := schedSetscheduler(
0, // calling thread
p.SchedPolicy,
&schedParam{0},
&param,
); err != nil {
return &StartError{
Fatal: true,

View File

@@ -1,6 +1,9 @@
package container
import (
"encoding"
"strconv"
"sync"
. "syscall"
"unsafe"
@@ -43,18 +46,132 @@ func Isatty(fd int) bool {
return r == 0
}
// SchedPolicy denotes a scheduling policy defined in include/uapi/linux/sched.h.
type SchedPolicy int
// include/uapi/linux/sched.h
const (
SCHED_NORMAL = iota
SCHED_NORMAL SchedPolicy = iota
SCHED_FIFO
SCHED_RR
SCHED_BATCH
_ // SCHED_ISO: reserved but not implemented yet
_SCHED_ISO // SCHED_ISO: reserved but not implemented yet
SCHED_IDLE
SCHED_DEADLINE
SCHED_EXT
_SCHED_LAST SchedPolicy = iota - 1
)
var _ encoding.TextMarshaler = _SCHED_LAST
var _ encoding.TextUnmarshaler = new(_SCHED_LAST)
// String returns a unique representation of policy, also used in encoding.
func (policy SchedPolicy) String() string {
switch policy {
case SCHED_NORMAL:
return ""
case SCHED_FIFO:
return "fifo"
case SCHED_RR:
return "rr"
case SCHED_BATCH:
return "batch"
case SCHED_IDLE:
return "idle"
case SCHED_DEADLINE:
return "deadline"
case SCHED_EXT:
return "ext"
default:
return "invalid policy " + strconv.Itoa(int(policy))
}
}
// MarshalText performs bounds checking and returns the result of String.
func (policy SchedPolicy) MarshalText() ([]byte, error) {
if policy == _SCHED_ISO || policy < 0 || policy > _SCHED_LAST {
return nil, EINVAL
}
return []byte(policy.String()), nil
}
// InvalidSchedPolicyError is an invalid string representation of a [SchedPolicy].
type InvalidSchedPolicyError string
func (InvalidSchedPolicyError) Unwrap() error { return EINVAL }
func (e InvalidSchedPolicyError) Error() string {
return "invalid scheduling policy " + strconv.Quote(string(e))
}
// UnmarshalText is the inverse of MarshalText.
func (policy *SchedPolicy) UnmarshalText(text []byte) error {
switch string(text) {
case "fifo":
*policy = SCHED_FIFO
case "rr":
*policy = SCHED_RR
case "batch":
*policy = SCHED_BATCH
case "idle":
*policy = SCHED_IDLE
case "deadline":
*policy = SCHED_DEADLINE
case "ext":
*policy = SCHED_EXT
case "":
*policy = 0
return nil
default:
return InvalidSchedPolicyError(text)
}
return nil
}
// for sched_get_priority_max and sched_get_priority_min
var (
schedPriority [_SCHED_LAST + 1][2]std.Int
schedPriorityErr [_SCHED_LAST + 1][2]error
schedPriorityOnce [_SCHED_LAST + 1][2]sync.Once
)
// GetPriorityMax returns the maximum priority value that can be used with the
// scheduling algorithm identified by policy.
func (policy SchedPolicy) GetPriorityMax() (std.Int, error) {
schedPriorityOnce[policy][0].Do(func() {
priority, _, errno := Syscall(
SYS_SCHED_GET_PRIORITY_MAX,
uintptr(policy),
0, 0,
)
schedPriority[policy][0] = std.Int(priority)
if schedPriority[policy][0] < 0 {
schedPriorityErr[policy][0] = errno
}
})
return schedPriority[policy][0], schedPriorityErr[policy][0]
}
// GetPriorityMin returns the minimum priority value that can be used with the
// scheduling algorithm identified by policy.
func (policy SchedPolicy) GetPriorityMin() (std.Int, error) {
schedPriorityOnce[policy][1].Do(func() {
priority, _, errno := Syscall(
SYS_SCHED_GET_PRIORITY_MIN,
uintptr(policy),
0, 0,
)
schedPriority[policy][1] = std.Int(priority)
if schedPriority[policy][1] < 0 {
schedPriorityErr[policy][1] = errno
}
})
return schedPriority[policy][1], schedPriorityErr[policy][1]
}
// schedParam is equivalent to struct sched_param from include/linux/sched.h.
type schedParam struct {
// sched_priority
@@ -74,7 +191,7 @@ type schedParam struct {
// this if you do not have something similar in place!
//
// [very subtle to use correctly]: https://www.openwall.com/lists/musl/2016/03/01/4
func schedSetscheduler(tid, policy int, param *schedParam) error {
func schedSetscheduler(tid int, policy SchedPolicy, param *schedParam) error {
if r, _, errno := Syscall(
SYS_SCHED_SETSCHEDULER,
uintptr(tid),

100
container/syscall_test.go Normal file
View File

@@ -0,0 +1,100 @@
package container_test
import (
"encoding/json"
"errors"
"math"
"reflect"
"syscall"
"testing"
"hakurei.app/container"
"hakurei.app/container/std"
)
func TestSchedPolicyJSON(t *testing.T) {
t.Parallel()
testCases := []struct {
policy container.SchedPolicy
want string
encodeErr error
decodeErr error
}{
{container.SCHED_NORMAL, `""`, nil, nil},
{container.SCHED_FIFO, `"fifo"`, nil, nil},
{container.SCHED_RR, `"rr"`, nil, nil},
{container.SCHED_BATCH, `"batch"`, nil, nil},
{4, `"invalid policy 4"`, syscall.EINVAL, container.InvalidSchedPolicyError("invalid policy 4")},
{container.SCHED_IDLE, `"idle"`, nil, nil},
{container.SCHED_DEADLINE, `"deadline"`, nil, nil},
{container.SCHED_EXT, `"ext"`, nil, nil},
{math.MaxInt, `"iso"`, syscall.EINVAL, container.InvalidSchedPolicyError("iso")},
}
for _, tc := range testCases {
name := tc.policy.String()
if tc.policy == container.SCHED_NORMAL {
name = "normal"
}
t.Run(name, func(t *testing.T) {
t.Parallel()
got, err := json.Marshal(tc.policy)
if !errors.Is(err, tc.encodeErr) {
t.Fatalf("Marshal: error = %v, want %v", err, tc.encodeErr)
}
if err == nil && string(got) != tc.want {
t.Fatalf("Marshal: %s, want %s", string(got), tc.want)
}
var v container.SchedPolicy
if err = json.Unmarshal([]byte(tc.want), &v); !reflect.DeepEqual(err, tc.decodeErr) {
t.Fatalf("Unmarshal: error = %v, want %v", err, tc.decodeErr)
}
if err == nil && v != tc.policy {
t.Fatalf("Unmarshal: %d, want %d", v, tc.policy)
}
})
}
}
func TestSchedPolicyMinMax(t *testing.T) {
t.Parallel()
testCases := []struct {
policy container.SchedPolicy
min, max std.Int
err error
}{
{container.SCHED_NORMAL, 0, 0, nil},
{container.SCHED_FIFO, 1, 99, nil},
{container.SCHED_RR, 1, 99, nil},
{container.SCHED_BATCH, 0, 0, nil},
{4, -1, -1, syscall.EINVAL},
{container.SCHED_IDLE, 0, 0, nil},
{container.SCHED_DEADLINE, 0, 0, nil},
{container.SCHED_EXT, 0, 0, nil},
}
for _, tc := range testCases {
name := tc.policy.String()
if tc.policy == container.SCHED_NORMAL {
name = "normal"
}
t.Run(name, func(t *testing.T) {
t.Parallel()
if priority, err := tc.policy.GetPriorityMax(); !reflect.DeepEqual(err, tc.err) {
t.Fatalf("GetPriorityMax: error = %v, want %v", err, tc.err)
} else if priority != tc.max {
t.Fatalf("GetPriorityMax: %d, want %d", priority, tc.max)
}
if priority, err := tc.policy.GetPriorityMin(); !reflect.DeepEqual(err, tc.err) {
t.Fatalf("GetPriorityMin: error = %v, want %v", err, tc.err)
} else if priority != tc.min {
t.Fatalf("GetPriorityMin: %d, want %d", priority, tc.min)
}
})
}
}

12
flake.lock generated
View File

@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1765384171,
"narHash": "sha256-FuFtkJrW1Z7u+3lhzPRau69E0CNjADku1mLQQflUORo=",
"lastModified": 1772985280,
"narHash": "sha256-FdrNykOoY9VStevU4zjSUdvsL9SzJTcXt4omdEDZDLk=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "44777152652bc9eacf8876976fa72cc77ca8b9d8",
"rev": "8f736f007139d7f70752657dff6a401a585d6cbc",
"type": "github"
},
"original": {
@@ -23,11 +23,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1765311797,
"narHash": "sha256-mSD5Ob7a+T2RNjvPvOA1dkJHGVrNVl8ZOrAwBjKBDQo=",
"lastModified": 1772822230,
"narHash": "sha256-yf3iYLGbGVlIthlQIk5/4/EQDZNNEmuqKZkQssMljuw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "09eb77e94fa25202af8f3e81ddc7353d9970ac1b",
"rev": "71caefce12ba78d84fe618cf61644dce01cf3a96",
"type": "github"
},
"original": {

View File

@@ -99,7 +99,7 @@
hakurei = pkgs.pkgsStatic.callPackage ./package.nix {
inherit (pkgs)
# passthru.buildInputs
go
go_1_26
clang
# nativeBuildInputs
@@ -182,7 +182,7 @@
let
# this is used for interactive vm testing during development, where tests might be broken
package = self.packages.${pkgs.stdenv.hostPlatform.system}.hakurei.override {
buildGoModule = previousArgs: pkgs.pkgsStatic.buildGoModule (previousArgs // { doCheck = false; });
buildGo126Module = previousArgs: pkgs.pkgsStatic.buildGo126Module (previousArgs // { doCheck = false; });
};
in
{

2
go.mod
View File

@@ -1,3 +1,3 @@
module hakurei.app
go 1.25
go 1.26

View File

@@ -1,69 +0,0 @@
//go:generate gocc -a azalea.bnf
package azalea
import (
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"hakurei.app/container/check"
)
type Parser struct {
Generator
}
func NewParser(gen Generator) *Parser {
return &Parser{
Generator: gen,
}
}
func (p Parser) Initialise() {
}
func (p Parser) Consume(ns string, file io.Reader) error {
return nil
}
// ConsumeDir walks a directory and consumes all Azalea source files within it and all its subdirectories, as long as they end with the .az extension.
func (p Parser) ConsumeDir(dir *check.Absolute) error {
ds := dir.String()
return filepath.WalkDir(ds, func(path string, d fs.DirEntry, err error) (e error) {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".az") {
return
}
rel, e := filepath.Rel(ds, path)
ns := strings.TrimSuffix(rel, ".az")
f, e := os.Open(path)
return p.Consume(ns, f)
})
}
// ConsumeAll consumes all provided readers as Azalea source code, each given the namespace `r%d` where `%d` is the index of the reader in the provided arguments.
func (p Parser) ConsumeAll(in ...io.Reader) error {
for i, r := range in {
err := p.Consume("r"+strconv.FormatInt(int64(i), 10), r)
if err != nil {
return err
}
}
return nil
}
// ConsumeStrings consumes all provided strings as Azalea source code, each given the namespace `s%d` where `%d` is the index of the string in the provided arugments.
func (p Parser) ConsumeStrings(in ...string) error {
for i, s := range in {
err := p.Consume("s"+strconv.FormatInt(int64(i), 10), strings.NewReader(s))
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,36 +0,0 @@
package azalea
import (
"io"
)
type Generator interface {
Finalise() (error, io.Writer)
}
type JsonGenerator struct {
t any
}
func NewJsonGenerator[T any]() JsonGenerator {
t := new(T)
return JsonGenerator{
t,
}
}
func (j *JsonGenerator) Finalise() (error, io.Writer) {
}
type PkgIRGenerator struct {
}
func NewPkgIRGenerator() PkgIRGenerator {
return PkgIRGenerator{}
}
func (p *PkgIRGenerator) Finalise() (error, io.Writer) {
}

View File

@@ -40,7 +40,7 @@ type ExecPath struct {
}
// SchedPolicy is the [container] scheduling policy.
var SchedPolicy int
var SchedPolicy container.SchedPolicy
// PromoteLayers returns artifacts with identical-by-content layers promoted to
// the highest priority instance, as if mounted via [ExecPath].

View File

@@ -82,6 +82,11 @@ install -Dm0500 \
echo "Installing linux $1..."
cp -av "$2" "$4"
cp -av "$3" "$4"
`))),
pkg.Path(AbsUsrSrc.Append(
".depmod",
), false, pkg.NewFile("depmod", []byte(`#!/bin/sh
exec /system/sbin/depmod -m /lib/modules "$@"
`))),
},
@@ -1210,6 +1215,11 @@ cgit 1.2.3-korg
"all",
},
Install: `
# kernel is not aware of kmod moduledir
install -Dm0500 \
/usr/src/.depmod \
/sbin/depmod
make \
"-j$(nproc)" \
-f /usr/src/kernel/Makefile \
@@ -1217,9 +1227,10 @@ make \
LLVM=1 \
INSTALL_PATH=/work \
install \
INSTALL_MOD_PATH=/work \
INSTALL_MOD_PATH=/work/system \
DEPMOD=/sbin/depmod \
modules_install
rm -v /work/lib/modules/` + kernelVersion + `/build
rm -v /work/system/lib/modules/` + kernelVersion + `/build
`,
},
Flex,

View File

@@ -14,6 +14,7 @@ func (t Toolchain) newKmod() (pkg.Artifact, string) {
pkg.TarGzip,
), nil, &MesonHelper{
Setup: [][2]string{
{"Dmoduledir", "/system/lib/modules"},
{"Dsysconfdir", "/system/etc"},
{"Dbashcompletiondir", "no"},
{"Dfishcompletiondir", "no"},

View File

@@ -125,6 +125,8 @@ func (t Toolchain) newLLVMVariant(variant string, attr *llvmAttr) pkg.Artifact {
[2]string{"LLVM_INSTALL_BINUTILS_SYMLINKS", "ON"},
[2]string{"LLVM_INSTALL_CCTOOLS_SYMLINKS", "ON"},
[2]string{"LLVM_LIT_ARGS", "'--verbose'"},
)
}

View File

@@ -1,7 +1,7 @@
{
lib,
stdenv,
buildGoModule,
buildGo126Module,
makeBinaryWrapper,
xdg-dbus-proxy,
pkg-config,
@@ -17,7 +17,7 @@
fuse3,
# for passthru.buildInputs
go,
go_1_26,
clang,
# for check
@@ -28,7 +28,7 @@
withStatic ? stdenv.hostPlatform.isStatic,
}:
buildGoModule rec {
buildGo126Module rec {
pname = "hakurei";
version = "0.3.6";
@@ -51,7 +51,7 @@ buildGoModule rec {
];
nativeBuildInputs = [
go
go_1_26
pkg-config
wayland-scanner
];
@@ -125,8 +125,11 @@ buildGoModule rec {
--inherit-argv0 --prefix PATH : ${lib.makeBinPath appPackages}
'';
passthru.targetPkgs = [
go
passthru = {
go = go_1_26;
targetPkgs = [
go_1_26
clang
xorg.xorgproto
util-linux
@@ -137,4 +140,5 @@ buildGoModule rec {
]
++ buildInputs
++ nativeBuildInputs;
};
}

View File

@@ -34,7 +34,7 @@ testers.nixosTest {
(writeShellScriptBin "hakurei-test" ''
# Assert hst CGO_ENABLED=0: ${
with pkgs;
runCommand "hakurei-hst-cgo" { nativeBuildInputs = [ go ]; } ''
runCommand "hakurei-hst-cgo" { nativeBuildInputs = [ self.packages.${system}.hakurei.go ]; } ''
cp -r ${options.environment.hakurei.package.default.src} "$out"
chmod -R +w "$out"
cp ${writeText "hst_cgo_test.go" ''package hakurei_test;import("testing";"hakurei.app/hst");func TestTemplate(t *testing.T){hst.Template()}''} "$out/hst_cgo_test.go"