aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitea/README5
-rw-r--r--.gitea/workflows/release.yml25
-rw-r--r--.gitea/workflows/test.yml130
-rw-r--r--cmd/mbf/internal/ci/ci.go522
-rw-r--r--cmd/mbf/internal/ci/ci_test.go55
-rw-r--r--cmd/mbf/main.go37
-rw-r--r--internal/rosa/llvm_test.go2
-rw-r--r--internal/rosa/mirror.go1
-rw-r--r--internal/rosa/package/gnu.az18
-rw-r--r--internal/rosa/package/gzip/late-include.patch21
-rw-r--r--internal/rosa/package/gzip/package.az18
-rw-r--r--internal/workflows/doc.go10
-rw-r--r--internal/workflows/main.go149
-rw-r--r--internal/workflows/release.go37
-rw-r--r--internal/workflows/test.go227
-rw-r--r--pkg/archive.go67
-rw-r--r--pkg/archive_test.go53
-rw-r--r--pkg/pkg.go3
-rw-r--r--pkg/pkg_test.go10
-rw-r--r--pkg/tar_test.go7
20 files changed, 1175 insertions, 222 deletions
diff --git a/.gitea/README b/.gitea/README
new file mode 100644
index 00000000..5a1d49e0
--- /dev/null
+++ b/.gitea/README
@@ -0,0 +1,5 @@
+This file is generated by 'go generate'. DO NOT EDIT.
+
+This directory holds workflow documents generated by internal/workflows. Any
+changes made here are overwritten on the next 'go generate' call. This setup
+avoids having to deal with yaml.
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 134d8bd1..d4eb0de8 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -1,24 +1 @@
-name: Release
-
-on:
- push:
- tags:
- - 'v*'
-
-jobs:
- release:
- name: Create release
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Build for release
- run: nix build --print-out-paths --print-build-logs ./test#dist
-
- - name: Release
- uses: https://gitea.com/actions/release-action@main
- with:
- files: |-
- result/hakurei-**
- api_key: '${{secrets.RELEASE_TOKEN}}'
+{"name":"Release","on":[{"K":"push","V":[{"K":"tags","V":"v*"}]}],"jobs":{"release":{"name":"Create release","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Build for release","run":"nix build --print-out-paths --print-build-logs ./test#dist"},{"name":"Release","uses":"https://gitea.com/actions/release-action@main","with":{"files":"result/hakurei-**","api_key":"${{secrets.RELEASE_TOKEN}}"}}]}}}
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index d1fc7ef8..0c29eb9e 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -1,129 +1 @@
-name: Test
-
-on:
- - push
-
-jobs:
- hakurei:
- name: Hakurei
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Run NixOS test
- run: nix build --out-link "result" --print-out-paths --print-build-logs ./test#checks.x86_64-linux.hakurei
-
- - name: Upload test output
- uses: actions/upload-artifact@v3
- with:
- name: "hakurei-vm-output"
- path: result/*
- retention-days: 1
-
- race:
- name: Hakurei (race detector)
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Run NixOS test
- run: nix build --out-link "result" --print-out-paths --print-build-logs ./test#checks.x86_64-linux.race
-
- - name: Upload test output
- uses: actions/upload-artifact@v3
- with:
- name: "hakurei-race-vm-output"
- path: result/*
- retention-days: 1
-
- sandbox:
- name: Sandbox
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Run NixOS test
- run: nix build --out-link "result" --print-out-paths --print-build-logs ./test#checks.x86_64-linux.sandbox
-
- - name: Upload test output
- uses: actions/upload-artifact@v3
- with:
- name: "sandbox-vm-output"
- path: result/*
- retention-days: 1
-
- sandbox-race:
- name: Sandbox (race detector)
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Run NixOS test
- run: nix build --out-link "result" --print-out-paths --print-build-logs ./test#checks.x86_64-linux.sandbox-race
-
- - name: Upload test output
- uses: actions/upload-artifact@v3
- with:
- name: "sandbox-race-vm-output"
- path: result/*
- retention-days: 1
-
- sharefs:
- name: ShareFS
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Run NixOS test
- run: nix build --out-link "result" --print-out-paths --print-build-logs ./test#checks.x86_64-linux.sharefs
-
- - name: Upload test output
- uses: actions/upload-artifact@v3
- with:
- name: "sharefs-vm-output"
- path: result/*
- retention-days: 1
-
- check:
- name: Flake checks
- needs:
- - hakurei
- - race
- - sandbox
- - sandbox-race
- - sharefs
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Run checks
- run: nix --print-build-logs --experimental-features 'nix-command flakes' flake check ./test
-
- dist:
- name: Create distribution
- runs-on: nix
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Build for test
- id: build-test
- run: >-
- export HAKUREI_REV="$(git rev-parse --short HEAD)" &&
- sed -i.old 's/version = /version = "0.0.0-'$HAKUREI_REV'"; # version = /' test/package.nix &&
- nix build --print-out-paths --print-build-logs ./test#dist &&
- mv test/{package.nix.old,package.nix} &&
- echo "rev=$HAKUREI_REV" >> $GITHUB_OUTPUT
-
- - name: Upload test build
- uses: actions/upload-artifact@v3
- with:
- name: "hakurei-${{ steps.build-test.outputs.rev }}"
- path: result/*
- retention-days: 1
+{"name":"Test","on":["push"],"jobs":{"hakurei":{"name":"Hakurei","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Run NixOS test","run":"nix build --out-link result --print-out-paths --print-build-logs ./test#checks.x86_64-linux.hakurei"},{"name":"Upload test output","uses":"actions/upload-artifact@v3","with":{"name":"hakurei-vm-output","path":"result/*","retention-days":1}}]},"race":{"name":"Hakurei (race detector)","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Run NixOS test","run":"nix build --out-link result --print-out-paths --print-build-logs ./test#checks.x86_64-linux.race"},{"name":"Upload test output","uses":"actions/upload-artifact@v3","with":{"name":"hakurei-race-vm-output","path":"result/*","retention-days":1}}]},"sandbox":{"name":"Sandbox","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Run NixOS test","run":"nix build --out-link result --print-out-paths --print-build-logs ./test#checks.x86_64-linux.sandbox"},{"name":"Upload test output","uses":"actions/upload-artifact@v3","with":{"name":"sandbox-vm-output","path":"result/*","retention-days":1}}]},"sandbox-race":{"name":"Sandbox (race detector)","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Run NixOS test","run":"nix build --out-link result --print-out-paths --print-build-logs ./test#checks.x86_64-linux.sandbox-race"},{"name":"Upload test output","uses":"actions/upload-artifact@v3","with":{"name":"sandbox-race-vm-output","path":"result/*","retention-days":1}}]},"sharefs":{"name":"ShareFS","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Run NixOS test","run":"nix build --out-link result --print-out-paths --print-build-logs ./test#checks.x86_64-linux.sharefs"},{"name":"Upload test output","uses":"actions/upload-artifact@v3","with":{"name":"sharefs-vm-output","path":"result/*","retention-days":1}}]},"check":{"name":"Flake checks","needs":["hakurei","race","sandbox","sandbox-race","sharefs"],"runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Run checks","run":"nix --print-build-logs --experimental-features 'nix-command flakes' flake check ./test"}]},"dist":{"name":"Create distribution","runs-on":"nix","steps":[{"name":"Checkout","uses":"actions/checkout@v4"},{"name":"Build for test","id":"build-test","run":"export HAKUREI_REV=\"$(git rev-parse --short HEAD)\" \u0026\u0026 sed -i.old 's/version = /version = \"0.0.0-'$HAKUREI_REV'\"; # version = /' test/package.nix \u0026\u0026 nix build --print-out-paths --print-build-logs ./test#dist \u0026\u0026 mv test/{package.nix.old,package.nix} \u0026\u0026 echo \"rev=$HAKUREI_REV\" \u003e\u003e $GITHUB_OUTPUT"},{"name":"Upload test build","uses":"actions/upload-artifact@v3","with":{"name":"hakurei-${{ steps.build-test.outputs.rev }}","path":"result/*","retention-days":1}}]}}}
diff --git a/cmd/mbf/internal/ci/ci.go b/cmd/mbf/internal/ci/ci.go
new file mode 100644
index 00000000..47c63d67
--- /dev/null
+++ b/cmd/mbf/internal/ci/ci.go
@@ -0,0 +1,522 @@
+// Package ci implements the CI service and client.
+package ci
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/ed25519"
+ "encoding/binary"
+ "errors"
+ "io"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+ "unique"
+ "unsafe"
+ _ "unsafe" // for go:linkname
+
+ "hakurei.app/internal/rosa"
+ "hakurei.app/message"
+ "hakurei.app/pkg"
+)
+
+// setSource is made available here to accept prepared hakurei tarballs.
+//
+//go:linkname setSource hakurei.app/internal/rosa.(*S).setSource
+func setSource(s *rosa.S, p []byte, version string)
+
+// inotifyInit returns a new inotify instance.
+func inotifyInit() (*os.File, error) {
+ fd, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
+ if err != nil {
+ return nil, os.NewSyscallError("inotify_init1", err)
+ }
+ return os.NewFile(uintptr(fd), "inotify"), nil
+}
+
+// inotifyAddWatch adds pathname to in.
+func inotifyAddWatch(
+ in *os.File,
+ pathname string,
+ mask uint32,
+) (watchdesc int, err error) {
+ sc, _err := in.SyscallConn()
+ if _err != nil {
+ return -1, _err
+ }
+ if _err = sc.Control(func(fd uintptr) {
+ watchdesc, err = syscall.InotifyAddWatch(int(fd), pathname, mask)
+ }); _err != nil {
+ return -1, _err
+ }
+ return
+}
+
+// Follow reads from the file at pathname and writes its contents and any new
+// contents to w. follow returns if a read, write or inotify error occurs, or
+// the context is canceled.
+func Follow(ctx context.Context, pathname string, w io.Writer) (err error) {
+ var in *os.File
+ if in, err = inotifyInit(); err != nil {
+ return
+ }
+ defer func() {
+ if _err := in.Close(); err == nil {
+ err = _err
+ }
+ }()
+
+ if _, err = inotifyAddWatch(in, pathname, syscall.IN_MODIFY); err != nil {
+ return
+ }
+ var r *os.File
+ if r, err = os.Open(pathname); err != nil {
+ return
+ }
+ defer func() {
+ if _err := r.Close(); err == nil {
+ err = _err
+ }
+ }()
+
+ done := make(chan struct{})
+ defer close(done)
+ go func() {
+ select {
+ case <-ctx.Done():
+ now := time.Now()
+ _ = in.SetDeadline(now)
+ return
+
+ case <-done:
+ return
+ }
+ }()
+
+ if _, err = io.Copy(w, r); err != nil {
+ return
+ }
+
+ buf := make([]byte, os.Getpagesize())
+ for {
+ if _, err = io.Copy(w, r); err != nil {
+ return
+ }
+ if _, err = in.Read(buf); err != nil {
+ if errors.Is(err, os.ErrDeadlineExceeded) {
+ err = nil
+ }
+ return
+ }
+ }
+}
+
+// Path wraps the [pkg.Cache] pathname.
+type Path string
+
+// String returns the value of p.
+func (p Path) String() string { return string(p) }
+
+// append is [filepath.Join] with p as the first element.
+func (p Path) append(elem ...string) string {
+ return filepath.Join(append([]string{p.String()}, elem...)...)
+}
+
+// New returns a new [Path].
+func New(c *pkg.Cache) Path { return Path(c.Path().String()) }
+
+// name returns the CI socket pathname.
+func (p Path) name() string { return p.append("ci") }
+
+// client returns the CI http client.
+func (p Path) client() *http.Client {
+ var d net.Dialer
+ addr := net.UnixAddr{
+ Net: "unix",
+ Name: p.name(),
+ }
+
+ return &http.Client{Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return d.DialUnix(ctx, "unix", nil, &addr)
+ },
+ }}
+}
+
+// cure writes the identifier of the pending artifact, cures the artifact, and
+// writes the cure whence. For an unsuccessful cure, a negative whence is
+// written, followed by a user-facing error string.
+func cure(c *pkg.Cache, a pkg.Artifact, w http.ResponseWriter) error {
+ h := w.Header()
+ h.Set("Content-Type", "application/octet-stream")
+ h.Set("Cache-Control", "no-cache")
+
+ f, ok := w.(http.Flusher)
+ if !ok {
+ _, _ = w.Write([]byte{0})
+ return errors.ErrUnsupported
+ }
+
+ id := c.Ident(a).Value()
+ if _, err := w.Write(id[:]); err != nil {
+ return err
+ }
+ f.Flush()
+
+ _, _, whence, err := c.CureWhence(a)
+ if err != nil {
+ whence = -1
+ }
+ if _, _err := w.Write(
+ binary.LittleEndian.AppendUint64(nil, uint64(whence)),
+ ); _err != nil {
+ return _err
+ }
+
+ if err != nil {
+ if _, _err := io.WriteString(w, err.Error()); _err != nil {
+ return _err
+ }
+ }
+ f.Flush()
+ return err
+}
+
+// spool holds reusable [rosa.S] instances.
+var spool = sync.Pool{New: func() any { return rosa.New() }}
+
+// getS returns the address of a populated [rosa.S]. Its hakurei-source may be
+// clobbered and must be replaced using setSource before use.
+func getS() *rosa.S { return spool.Get().(*rosa.S) }
+
+// putS returns s to spool.
+func putS(s *rosa.S) { spool.Put(s) }
+
+// versionSize is the maximum size of the specified version string, plus its
+// deliminator byte.
+const versionSize = 8 + 16 + 6 + 2
+
+// errBadVersion is returned by readSource if a header does not contain
+// the deliminator byte.
+var errBadVersion = errors.New("unterminated version string")
+
+// readSource reads a version string and compressed source tarball from r and
+// returns the address of a [rosa.S] with this source tarball. The resulting
+// [rosa.S] must be returned via putS.
+func readSource(w http.ResponseWriter, r *http.Request) (*rosa.S, error) {
+ var header [versionSize]byte
+ _, err := io.ReadFull(r.Body, header[:])
+ if err != nil {
+ _ = r.Body.Close()
+ http.Error(w, "bad header", http.StatusBadRequest)
+ return nil, err
+ }
+
+ var version string
+ if i := bytes.IndexByte(header[:], 0); i < 0 {
+ _ = r.Body.Close()
+ http.Error(w, "unterminated version string", http.StatusBadRequest)
+ return nil, errBadVersion
+ } else {
+ version = unsafe.String(&header[0], i)
+ }
+
+ var p []byte
+ if p, err = io.ReadAll(r.Body); err != nil {
+ _ = r.Body.Close()
+ http.Error(w, "cannot receive payload", http.StatusInternalServerError)
+ return nil, err
+ }
+
+ s := getS()
+ setSource(s, p, version)
+ return s, r.Body.Close()
+}
+
+// ErrDaemonError is returned by writeSource generally if cure could not flush
+// on the connection to notify completion.
+var ErrDaemonError = errors.New("CI service could not process the request")
+
+// writeSource writes a source tarball to the specified endpoint of the CI
+// backend servicing the cache referred to by cm.
+func (p Path) writeSource(
+ ctx context.Context,
+ w io.Writer,
+ endpoint, source, version string,
+) (*pkg.ID, error) {
+ if len(version) >= versionSize {
+ return nil, syscall.ENOMEM
+ }
+ var header [versionSize]byte
+ copy(header[:], version[:])
+
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gw)
+
+ if err := filepath.WalkDir(source, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if d.IsDir() && d.Name() == ".git" {
+ return fs.SkipDir
+ }
+
+ var fi fs.FileInfo
+ if fi, err = d.Info(); err != nil {
+ return err
+ }
+
+ var linkname string
+ if fi.Mode()&fs.ModeSymlink != 0 {
+ if linkname, err = os.Readlink(path); err != nil {
+ return err
+ }
+ }
+
+ var h *tar.Header
+ if h, err = tar.FileInfoHeader(fi, linkname); err != nil {
+ return err
+ }
+ h.Name = path
+
+ var isVersion bool
+ if dir, file := filepath.Split(path); filepath.Base(dir) == "dist" &&
+ file == "VERSION" && fi.Mode().IsRegular() {
+ isVersion = true
+ h.Size = int64(len(version))
+ }
+
+ if err = tw.WriteHeader(h); err != nil {
+ return err
+ }
+
+ if isVersion {
+ _, err = io.WriteString(tw, version)
+ return err
+ }
+
+ if fi.Mode().IsRegular() {
+ var f io.ReadCloser
+ if f, err = os.Open(path); err != nil {
+ return err
+ }
+
+ _, err = io.Copy(tw, f)
+ if _err := f.Close(); err == nil {
+ err = _err
+ }
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+ if err := tw.Close(); err != nil {
+ return nil, err
+ }
+ if err := gw.Close(); err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ "http://"+path.Join("_", endpoint),
+ io.MultiReader(bytes.NewReader(header[:]), bytes.NewReader(buf.Bytes())),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ var resp *http.Response
+ resp, err = p.client().Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ var id pkg.ID
+ _, err = io.ReadFull(resp.Body, id[:])
+ if err != nil {
+ _ = resp.Body.Close()
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ return nil, ErrDaemonError
+ }
+ return nil, err
+ }
+
+ c, cancel := context.WithCancel(ctx)
+ done := make(chan error, 1)
+ var whence int
+ go func() {
+ defer cancel()
+ var wbuf [8]byte
+ _, _err := io.ReadFull(resp.Body, wbuf[:])
+ whence = int(binary.LittleEndian.Uint64(wbuf[:]))
+ done <- _err
+ }()
+
+ if w != nil {
+ retry:
+ err = Follow(c, filepath.Join(p.String(), "status", pkg.Encode(id)), w)
+ if err != nil {
+ if ctx.Err() == nil && errors.Is(err, os.ErrNotExist) {
+ goto retry
+ }
+
+ _ = resp.Body.Close()
+ return nil, err
+ }
+ }
+
+ err = <-done
+ if err != nil {
+ _ = resp.Body.Close()
+ return nil, err
+ }
+
+ if whence < 0 {
+ var m []byte
+ if m, err = io.ReadAll(resp.Body); err != nil {
+ _ = resp.Body.Close()
+ return nil, err
+ } else if err = resp.Body.Close(); err != nil {
+ return nil, err
+ }
+ return nil, errors.New(unsafe.String(unsafe.SliceData(m), len(m)))
+ }
+ return &id, resp.Body.Close()
+}
+
+// The stubKey is used by the mirror service exposed by serve where
+// authentication is unnecessary.
+var stubKey = ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize))
+
+// fetch fetches the outcome of id and writes it to the specified directory.
+func (p Path) fetch(ctx context.Context, id *pkg.ID, output string) error {
+ c := p.client()
+ r, err := rosa.NewRemote(
+ c, "http://_",
+ stubKey.Public().(ed25519.PublicKey),
+ )
+ if err != nil {
+ return err
+ }
+
+ var sum *pkg.Checksum
+ if sum, err = r.Artifact(ctx, unique.Make(*id)); err != nil {
+ return err
+ }
+
+ var req *http.Request
+ if req, err = http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "http://"+path.Join("_", "outcome", pkg.Encode(*sum)),
+ nil,
+ ); err != nil {
+ return err
+ }
+
+ var resp *http.Response
+ if resp, err = c.Do(req); err != nil {
+ return err
+ }
+
+ err = pkg.Extract(resp.Body, output, nil)
+ if closeErr := resp.Body.Close(); err == nil {
+ err = closeErr
+ }
+ return err
+}
+
+// MakeDist creates a hakurei distribution using the CI service.
+func (p Path) MakeDist(
+ ctx context.Context,
+ w io.Writer,
+ output, source, version string,
+) error {
+ id, err := p.writeSource(ctx, w, "/dist", source, version)
+ if err != nil {
+ return err
+ }
+ return p.fetch(ctx, id, output)
+}
+
+// Serve services CI workload dispatched to c.
+func Serve(ctx context.Context, msg message.Msg, c *pkg.Cache) error {
+ const shutdownTimeout = 15 * time.Second
+ p := New(c)
+ addr := net.UnixAddr{
+ Net: "unix",
+ Name: p.name(),
+ }
+
+ var mux http.ServeMux
+ mux.HandleFunc("POST /dist", func(w http.ResponseWriter, r *http.Request) {
+ s, err := readSource(w, r)
+ if err != nil {
+ msg.Verbose(err)
+ return
+ }
+ defer putS(s)
+
+ _, a := s.Std().MustLoad(rosa.H("hakurei-dist"))
+ if err = cure(c, a, w); err != nil {
+ msg.Verbose(err)
+ return
+ }
+ if msg.IsVerbose() {
+ msg.Verbosef(
+ "satisfied distribution %s",
+ pkg.Encode(c.Ident(a).Value()),
+ )
+ }
+ })
+
+ if r, err := os.OpenRoot(p.String()); err != nil {
+ return err
+ } else {
+ defer func() {
+ if err = r.Close(); err != nil {
+ msg.Verbose(err)
+ }
+ }()
+ rosa.NewMirror(msg, r.FS(), stubKey).Register(&mux)
+ }
+
+ server := http.Server{Handler: &mux}
+ go func() {
+ <-ctx.Done()
+ cc, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if _err := server.Shutdown(cc); _err != nil {
+ log.Fatal(_err)
+ }
+ }()
+
+ ul, err := net.ListenUnix("unix", &addr)
+ if err != nil {
+ return err
+ }
+ ul.SetUnlinkOnClose(true)
+ msg.Verbosef("listening on %s", addr.Net)
+
+ err = server.Serve(ul)
+ if errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ return err
+}
diff --git a/cmd/mbf/internal/ci/ci_test.go b/cmd/mbf/internal/ci/ci_test.go
new file mode 100644
index 00000000..b5ed134e
--- /dev/null
+++ b/cmd/mbf/internal/ci/ci_test.go
@@ -0,0 +1,55 @@
+package ci_test
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "hakurei.app/cmd/mbf/internal/ci"
+)
+
+func TestFollow(t *testing.T) {
+ t.Parallel()
+
+ pathname := filepath.Join(t.TempDir(), "f")
+ w, err := os.Create(pathname)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ ctx, cancel := context.WithCancel(t.Context())
+
+ var want string
+ go func() {
+ defer cancel()
+ for _, s := range []string{
+ "\xde\xad\xbe\xef",
+ "\xff\xff\xff\xff",
+ "\x00\x00",
+ } {
+ want += s
+ if _, _err := w.WriteString(s); _err != nil {
+ panic(_err)
+ }
+ }
+ }()
+
+retry:
+ if err = ci.Follow(ctx, pathname, &buf); err != nil {
+ t.Fatal(err)
+ }
+ <-ctx.Done()
+
+ // the inotify event takes time to arrive, and there is no way to
+ // synchronise for this cleanly
+ if buf.Len() != len(want) {
+ goto retry
+ }
+
+ if got := buf.String(); got != want {
+ t.Fatalf("follow: %q, want %q", got, want)
+ }
+}
diff --git a/cmd/mbf/main.go b/cmd/mbf/main.go
index 22bfbc3e..014a7f47 100644
--- a/cmd/mbf/main.go
+++ b/cmd/mbf/main.go
@@ -43,6 +43,7 @@ import (
"hakurei.app/message"
"hakurei.app/pkg"
+ "hakurei.app/cmd/mbf/internal/ci"
"hakurei.app/cmd/mbf/internal/pkgsite"
"hakurei.app/cmd/mbf/internal/pkgsite/ui"
)
@@ -504,7 +505,7 @@ func main() {
c.NewCommand(
"daemon",
"Service artifact IR with Rosa OS extensions",
- func(args []string) error {
+ func([]string) error {
ul, err := net.ListenUnix("unix", &addr)
if err != nil {
return err
@@ -514,6 +515,40 @@ func main() {
},
)
+ _ci := c.New("ci", command.UsageInternal)
+ _ci.NewCommand(
+ "daemon",
+ "Service CI workload dispatched through the socket",
+ func([]string) error {
+ return cm.Do(func(cache *pkg.Cache) error {
+ return ci.Serve(ctx, msg, cache)
+ })
+ },
+ )
+ {
+ var flagOutput string
+ _ci.NewCommand(
+ "dist",
+ "Request distribution tarball for the specified source directory",
+ func(args []string) error {
+ if len(args) != 2 {
+ return errors.New("dist requires 2 arguments")
+ }
+
+ return ci.Path(cm.base).MakeDist(
+ ctx,
+ os.Stdout,
+ flagOutput,
+ args[0], args[1],
+ )
+ },
+ ).Flag(
+ &flagOutput,
+ "o", command.StringFlag("."),
+ "Write the resulting distribution to the named directory",
+ )
+ }
+
c.NewCommand(
"keygen",
"Create keypair for local cache",
diff --git a/internal/rosa/llvm_test.go b/internal/rosa/llvm_test.go
index 3152107c..3fa8fc9d 100644
--- a/internal/rosa/llvm_test.go
+++ b/internal/rosa/llvm_test.go
@@ -8,7 +8,7 @@ import (
)
func TestLLVMInputs(t *testing.T) {
- const wantInputCount = 466
+ const wantInputCount = 472
_, llvm := rosa.MustLoad(rosa.H("llvm"))
var n int
diff --git a/internal/rosa/mirror.go b/internal/rosa/mirror.go
index 4af931c7..20dd3231 100644
--- a/internal/rosa/mirror.go
+++ b/internal/rosa/mirror.go
@@ -451,7 +451,6 @@ func writeF[B any](
return
}
m.msg.GetLogger().Println(err)
- w.WriteHeader(http.StatusInternalServerError)
}
// Register configures an [http.ServeMux] for servicing mirror requests.
diff --git a/internal/rosa/package/gnu.az b/internal/rosa/package/gnu.az
index c3f1be64..2c408206 100644
--- a/internal/rosa/package/gnu.az
+++ b/internal/rosa/package/gnu.az
@@ -125,24 +125,6 @@ package libtool {
];
}
-package gzip {
- description = "a popular data compression program";
- website = "https://www.gnu.org/software/gzip";
- anitya = 1290;
-
- version# = "1.15";
- source = remoteTar {
- url = "https://mirrors.kernel.org/gnu/gzip/gzip-"+version+".tar.gz";
- checksum = "N8v7cH-Vhmxvk1PYaF-B1vH-r-rMT3Z3VyMdCFXhBd74a8v62wT3dKes-u1JCvUG";
- compress = gzip;
- };
-
- exec = make {
- // dependency loop
- check = nil;
- };
-}
-
package sed {
description = "a non-interactive command-line text editor";
website = "https://www.gnu.org/software/sed";
diff --git a/internal/rosa/package/gzip/late-include.patch b/internal/rosa/package/gzip/late-include.patch
new file mode 100644
index 00000000..4638d578
--- /dev/null
+++ b/internal/rosa/package/gzip/late-include.patch
@@ -0,0 +1,21 @@
+diff --git a/gzip.c b/gzip.c
+index 220f6fc..ef79a89 100644
+--- a/gzip.c
++++ b/gzip.c
+@@ -56,6 +56,8 @@ static char const license_msg[] =
+
+ #include <config.h>
+
++#include <signal.h>
++
+ #include "tailor.h"
+
+ #include "gzip.h"
+@@ -79,7 +81,6 @@ static char const license_msg[] =
+ #include <inttypes.h>
+ #include <limits.h>
+ #include <locale.h>
+-#include <signal.h>
+ #include <stdcountof.h>
+ #include <stddef.h>
+ #include <stdlib.h>
diff --git a/internal/rosa/package/gzip/package.az b/internal/rosa/package/gzip/package.az
new file mode 100644
index 00000000..6c6b3b1f
--- /dev/null
+++ b/internal/rosa/package/gzip/package.az
@@ -0,0 +1,18 @@
+package gzip {
+ description = "a popular data compression program";
+ website = "https://www.gnu.org/software/gzip";
+ anitya = 1290;
+
+ version# = "1.15";
+ source = remoteTar {
+ url = "https://mirrors.kernel.org/gnu/gzip/gzip-"+version+".tar.gz";
+ checksum = "N8v7cH-Vhmxvk1PYaF-B1vH-r-rMT3Z3VyMdCFXhBd74a8v62wT3dKes-u1JCvUG";
+ compress = gzip;
+ };
+ patches = [ "late-include.patch" ];
+
+ exec = make {
+ // dependency loop
+ check = nil;
+ };
+}
diff --git a/internal/workflows/doc.go b/internal/workflows/doc.go
new file mode 100644
index 00000000..6a9dc5e6
--- /dev/null
+++ b/internal/workflows/doc.go
@@ -0,0 +1,10 @@
+//go:build !workflows
+
+// The workflows program manages the .gitea directory to avoid having to write
+// yaml. It is never invoked directly or built for the distribution tarball.
+//
+// This program is an internal detail of the hakurei project and is not usable
+// on its own. It is not covered by the compatibility promise.
+package workflows
+
+//go:generate go run -tags=workflows .
diff --git a/internal/workflows/main.go b/internal/workflows/main.go
new file mode 100644
index 00000000..46f7e054
--- /dev/null
+++ b/internal/workflows/main.go
@@ -0,0 +1,149 @@
+//go:build workflows
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// KV represents a string to value pair.
+type KV[T any] struct {
+ K string
+ V T
+}
+
+// A Map represents an ordered set of key-value pairs.
+type Map[T any] []KV[T]
+
+// MarshalJSON encodes m as a key-value pair in JSON.
+func (m *Map[T]) MarshalJSON() ([]byte, error) {
+ if m == nil || len(*m) == 0 {
+ return []byte("{}"), nil
+ }
+
+ buf := bytes.NewBuffer(make([]byte, 0, len(*m)<<5))
+ buf.WriteByte('{')
+
+ last := len(*m) - 1
+ for i, pair := range *m {
+ p, err := json.Marshal(pair.K)
+ if err != nil {
+ return nil, fmt.Errorf("key %q: %w", pair.K, err)
+ }
+ buf.Write(p)
+ buf.WriteByte(':')
+
+ p, err = json.Marshal(pair.V)
+ if err != nil {
+ return nil, fmt.Errorf("value of key %q: %w", pair.K, err)
+ }
+ buf.Write(p)
+
+ if i < last {
+ buf.WriteByte(',')
+ }
+ }
+
+ buf.WriteByte('}')
+ return buf.Bytes(), nil
+}
+
+// A Step is a task ran as part of a [Job].
+type Step struct {
+ // A name for the step.
+ Name string `json:"name,omitempty"`
+ // Selects an action to run as part of a step in the job.
+ Uses string `json:"uses,omitempty"`
+ // A unique identifier for the step.
+ ID string `json:"id,omitempty"`
+ // Prevent a step from running unless a condition is met.
+ If string `json:"if,omitempty"`
+ // Runs command-line programs that do not exceed 21,000 characters using the
+ // operating system's shell.
+ Run string `json:"run,omitempty"`
+ // Key-value pairs of input parameters defined by the action.
+ With Map[any] `json:"with,omitempty"`
+}
+
+// A Job is an entry in a [Workflow] document.
+type Job struct {
+ // Set a name for the job.
+ Name string `json:"name,omitempty"`
+ // Identifies any jobs that must complete successfully before this job will
+ // run.
+ Needs []string `json:"needs,omitempty"`
+ // Define the type of machine to run the job on.
+ On string `json:"runs-on,omitempty"`
+ // A sequence of tasks.
+ Steps []Step `json:"steps,omitempty"`
+}
+
+// Workflow describes a workflow document.
+type Workflow struct {
+ // The name of the workflow.
+ Name string `json:"name,omitempty"`
+ // The name for workflow runs generated from the workflow.
+ RunName string `json:"run-name,omitempty"`
+ // Define which events can cause the workflow to run.
+ On any `json:"on"`
+
+ // Named jobs part of the workflow run.
+ Jobs Map[Job] `json:"jobs,omitempty"`
+}
+
+// workflowsDir is the relative pathname of the workflows directory.
+const workflowsDir = "../../.gitea/workflows"
+
+// writeFile writes the action to the .gitea directory.
+func (w *Workflow) writeFile(name string) error {
+ f, err := os.OpenFile(
+ filepath.Join(workflowsDir, name+".yml"),
+ os.O_CREATE|os.O_EXCL|os.O_WRONLY,
+ 0444,
+ )
+ if err != nil {
+ return err
+ }
+
+ err = json.NewEncoder(f).Encode(w)
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ return err
+}
+
+// removeOnce synchronises removal of stale workflow files.
+var removeOnce sync.Once
+
+// removeWorkflows removes existing workflow files once.
+func removeWorkflows() {
+ removeOnce.Do(func() {
+ if err := os.RemoveAll(workflowsDir); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ if err := os.Mkdir(workflowsDir, 0755); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ })
+}
+
+// mustWriteFile is like writeFile, but terminates the program if a non-nil
+// error is returned.
+func (w *Workflow) mustWriteFile(name string) (_ struct{}) {
+ removeWorkflows()
+
+ if err := w.writeFile(name); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ return
+}
+
+func main() {}
diff --git a/internal/workflows/release.go b/internal/workflows/release.go
new file mode 100644
index 00000000..8b7f30bb
--- /dev/null
+++ b/internal/workflows/release.go
@@ -0,0 +1,37 @@
+//go:build workflows
+
+package main
+
+var _ = (&Workflow{
+ Name: "Release",
+ On: Map[any]{{K: "push", V: Map[string]{{K: "tags", V: "v*"}}}},
+
+ Jobs: []KV[Job]{
+ {K: "release", V: Job{
+ Name: "Create release",
+ On: "nix",
+
+ Steps: []Step{
+ {Name: "Checkout", Uses: "actions/checkout@v4"},
+
+ {
+ Name: "Build for release",
+ Run: "nix build " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./test#dist",
+ },
+
+ {
+ Name: "Release",
+ Uses: "https://gitea.com/actions/release-action@main",
+
+ With: []KV[any]{
+ {K: "files", V: "result/hakurei-**"},
+ {K: "api_key", V: "${{secrets.RELEASE_TOKEN}}"},
+ },
+ },
+ },
+ }},
+ },
+}).mustWriteFile("release")
diff --git a/internal/workflows/test.go b/internal/workflows/test.go
new file mode 100644
index 00000000..1bbb04f9
--- /dev/null
+++ b/internal/workflows/test.go
@@ -0,0 +1,227 @@
+//go:build workflows
+
+package main
+
+var _ = (&Workflow{
+ Name: "Test",
+ On: []any{"push"},
+
+ Jobs: Map[Job]{
+ {K: "hakurei", V: Job{
+ Name: "Hakurei",
+ On: "nix",
+
+ Steps: []Step{
+ {
+ Name: "Checkout",
+ Uses: "actions/checkout@v4",
+ },
+
+ {
+ Name: "Run NixOS test",
+ Run: "nix build " +
+ "--out-link result " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./test#checks.x86_64-linux.hakurei",
+ },
+
+ {
+ Name: "Upload test output",
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {K: "name", V: "hakurei-vm-output"},
+ {K: "path", V: "result/*"},
+
+ {K: "retention-days", V: 1},
+ },
+ },
+ },
+ }},
+
+ {K: "race", V: Job{
+ Name: "Hakurei (race detector)",
+ On: "nix",
+
+ Steps: []Step{
+ {Name: "Checkout", Uses: "actions/checkout@v4"},
+
+ {
+ Name: "Run NixOS test",
+ Run: "nix build " +
+ "--out-link result " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./test#checks.x86_64-linux.race",
+ },
+
+ {
+ Name: "Upload test output",
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {K: "name", V: "hakurei-race-vm-output"},
+ {K: "path", V: "result/*"},
+
+ {K: "retention-days", V: 1},
+ },
+ },
+ },
+ }},
+
+ {K: "sandbox", V: Job{
+ Name: "Sandbox",
+ On: "nix",
+
+ Steps: []Step{
+ {Name: "Checkout", Uses: "actions/checkout@v4"},
+
+ {
+ Name: "Run NixOS test",
+ Run: "nix build " +
+ "--out-link result " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./test#checks.x86_64-linux.sandbox",
+ },
+
+ {
+ Name: "Upload test output",
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {K: "name", V: "sandbox-vm-output"},
+ {K: "path", V: "result/*"},
+
+ {K: "retention-days", V: 1},
+ },
+ },
+ },
+ }},
+
+ {K: "sandbox-race", V: Job{
+ Name: "Sandbox (race detector)",
+ On: "nix",
+
+ Steps: []Step{
+ {Name: "Checkout", Uses: "actions/checkout@v4"},
+
+ {
+ Name: "Run NixOS test",
+ Run: "nix build " +
+ "--out-link result " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./test#checks.x86_64-linux.sandbox-race",
+ },
+
+ {
+ Name: "Upload test output",
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {K: "name", V: "sandbox-race-vm-output"},
+ {K: "path", V: "result/*"},
+
+ {K: "retention-days", V: 1},
+ },
+ },
+ },
+ }},
+
+ {K: "sharefs", V: Job{
+ Name: "ShareFS",
+ On: "nix",
+
+ Steps: []Step{
+ {Name: "Checkout", Uses: "actions/checkout@v4"},
+
+ {
+ Name: "Run NixOS test",
+ Run: "nix build " +
+ "--out-link result " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./test#checks.x86_64-linux.sharefs",
+ },
+
+ {
+ Name: "Upload test output",
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {K: "name", V: "sharefs-vm-output"},
+ {K: "path", V: "result/*"},
+
+ {K: "retention-days", V: 1},
+ },
+ },
+ },
+ }},
+
+ {K: "check", V: Job{
+ Name: "Flake checks",
+ On: "nix",
+
+ Needs: []string{
+ "hakurei",
+ "race",
+ "sandbox",
+ "sandbox-race",
+ "sharefs",
+ },
+
+ Steps: []Step{
+ {
+ Name: "Checkout",
+ Uses: "actions/checkout@v4",
+ },
+
+ {
+ Name: "Run checks",
+ Run: "nix " +
+ "--print-build-logs " +
+ "--experimental-features 'nix-command flakes' " +
+ "flake check ./test",
+ },
+ },
+ }},
+
+ {K: "dist", V: Job{
+ Name: "Create distribution",
+ On: "nix",
+
+ Steps: []Step{
+ {
+ Name: "Checkout",
+ Uses: "actions/checkout@v4",
+ },
+
+ {
+ Name: "Build for test",
+ ID: "build-test",
+ Run: "export HAKUREI_REV=\"$(git rev-parse --short HEAD)\" && " +
+ "sed -i.old " +
+ "'s/version = /version = \"0.0.0-'$HAKUREI_REV'\"; # version = /' " +
+ "test/package.nix && " +
+ "nix build --print-out-paths --print-build-logs ./test#dist && " +
+ "mv test/{package.nix.old,package.nix} && " +
+ "echo \"rev=$HAKUREI_REV\" >> $GITHUB_OUTPUT",
+ },
+
+ {
+ Name: "Upload test build",
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {K: "name", V: "hakurei-${{ steps.build-test.outputs.rev }}"},
+ {K: "path", V: "result/*"},
+
+ {K: "retention-days", V: 1},
+ },
+ },
+ },
+ }},
+ },
+}).mustWriteFile("test")
diff --git a/pkg/archive.go b/pkg/archive.go
index e9f76f5b..ba7c61b9 100644
--- a/pkg/archive.go
+++ b/pkg/archive.go
@@ -314,36 +314,25 @@ func (archiveArtifact) IsExclusive() bool { return false }
// Revision satisfies [RevisionArtifact] for status behaviour.
func (archiveArtifact) Revision() uint64 { return 0 }
-// Cure cures the [Artifact], producing a directory located at work.
-func (a archiveArtifact) Cure(t *TContext) (err error) {
- var status io.Writer
- if status, err = t.GetStatusWriter(); err != nil {
- return
- }
-
- var r io.ReadCloser
- if r, err = t.Open(a.f); err != nil {
- return
- }
-
- defer func() {
- closeErr := r.Close()
- if err == nil {
- err = closeErr
- }
- }()
-
+// Extract reads an archive stream from r and writes its contents to dir. If
+// writeStatus is non-nil, it is called with a user-facing message after
+// each successfully extracted entry.
+func Extract(
+ r io.Reader,
+ dir string,
+ writeStatus func(s string) error,
+) (err error) {
type dirTargetPerm struct {
path string
mode fs.FileMode
}
var madeDirectories []dirTargetPerm
- if err = os.MkdirAll(t.GetWorkDir().String(), 0700); err != nil {
+ if err = os.MkdirAll(dir, 0700); err != nil {
return
}
var root *os.Root
- if root, err = os.OpenRoot(t.GetWorkDir().String()); err != nil {
+ if root, err = os.OpenRoot(dir); err != nil {
return
}
defer func() {
@@ -355,7 +344,6 @@ func (a archiveArtifact) Cure(t *TContext) (err error) {
var header *ArchiveHeader
ar := NewReader(r)
- msg := t.GetMessage()
for header, err = ar.Next(); err == nil; header, err = ar.Next() {
var m string
if header.Mode.IsRegular() {
@@ -422,11 +410,8 @@ func (a archiveArtifact) Cure(t *TContext) (err error) {
return InvalidFileModeError(header.Mode)
}
- if m != "" {
- msg.Verbose(m)
- if _, err = io.WriteString(status, m); err != nil {
- return
- } else if _, err = status.Write([]byte{'\n'}); err != nil {
+ if writeStatus != nil && m != "" {
+ if err = writeStatus(m); err != nil {
return
}
}
@@ -446,6 +431,34 @@ func (a archiveArtifact) Cure(t *TContext) (err error) {
return
}
+// Cure cures the [Artifact], producing a directory located at work.
+func (a archiveArtifact) Cure(t *TContext) (err error) {
+ var status io.Writer
+ if status, err = t.GetStatusWriter(); err != nil {
+ return
+ }
+
+ var r io.ReadCloser
+ if r, err = t.Open(a.f); err != nil {
+ return
+ }
+
+ msg := t.GetMessage()
+ err = Extract(r, t.GetWorkDir().String(), func(s string) (err error) {
+ msg.Verbose(s)
+ if _, err = io.WriteString(status, s); err != nil {
+ return
+ }
+ _, err = status.Write([]byte{'\n'})
+ return
+ })
+
+ if closeErr := r.Close(); err == nil {
+ err = closeErr
+ }
+ return
+}
+
// CuresExempt exempts the cheap [KindArchive] implementation often found at
// the end of a [FileArtifact] pipeline.
func (archiveArtifact) CuresExempt() {}
diff --git a/pkg/archive_test.go b/pkg/archive_test.go
index 1ca631ee..0171d914 100644
--- a/pkg/archive_test.go
+++ b/pkg/archive_test.go
@@ -2,9 +2,12 @@ package pkg_test
import (
"bytes"
+ "crypto/sha512"
+ "errors"
"io"
"io/fs"
"maps"
+ "net/http"
"reflect"
"testing"
"testing/fstest"
@@ -176,33 +179,53 @@ func TestArchiveArtifact(t *testing.T) {
want := maps.Clone(archiveTestdata)
want["."].Mode = fs.ModeDir | 0500
+ var buf bytes.Buffer
+ if err := pkg.Write(archiveTestdata, ".", &buf); err != nil {
+ t.Fatal(err)
+ }
+ wantChecksum := sha512.Sum384(buf.Bytes())
+ wantChecksumEncode := pkg.Encode(wantChecksum)
+
+ var transport http.Transport
+ client := http.Client{Transport: &transport}
+ transport.RegisterProtocol("file", http.NewFileTransportFS(fstest.MapFS{
+ "testdata": {Data: buf.Bytes(), Mode: 0400},
+ }))
+
checkWithCache(t, []cacheTestCase{
{"unpack", 0, nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
- var buf bytes.Buffer
- if err := pkg.Write(archiveTestdata, ".", &buf); err != nil {
- t.Fatal(err)
- }
-
cureMany(t, c, []cureStep{
{"sample", pkg.NewArchive(
pkg.NewFile("", buf.Bytes()),
), ignorePathname, expectsFS(want), pkg.WNew, nil},
+
+ {"close error passthrough", pkg.NewArchive(
+ pkg.NewHTTPGet(
+ &client,
+ "file:///testdata",
+ pkg.Checksum{},
+ ),
+ ), nil, nil, pkg.WNew, errors.Join(&pkg.ChecksumMismatchError{
+ Got: wantChecksum,
+ })},
})
+
+ dropFaults(t, base)
}, expectsFS{
".": {Mode: fs.ModeDir | 0700},
- "checksum": {Mode: fs.ModeDir | 0700},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F": {Mode: fs.ModeDir | 0500},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/block": {Mode: fs.ModeDir | 0700},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/block/uevent": {Mode: 0600},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices": {Mode: fs.ModeDir | 0700},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/empty": {Mode: fs.ModeDir | 0700},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/sub": {Mode: fs.ModeDir | 0700},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/sub/uevent": {Mode: 0600, Data: []byte("add")},
- "checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F/devices/uevent": {Mode: 0600, Data: []byte("add")},
+ "checksum": {Mode: fs.ModeDir | 0700},
+ "checksum/" + wantChecksumEncode: {Mode: fs.ModeDir | 0500},
+ "checksum/" + wantChecksumEncode + "/block": {Mode: fs.ModeDir | 0700},
+ "checksum/" + wantChecksumEncode + "/block/uevent": {Mode: 0600},
+ "checksum/" + wantChecksumEncode + "/devices": {Mode: fs.ModeDir | 0700},
+ "checksum/" + wantChecksumEncode + "/devices/empty": {Mode: fs.ModeDir | 0700},
+ "checksum/" + wantChecksumEncode + "/devices/sub": {Mode: fs.ModeDir | 0700},
+ "checksum/" + wantChecksumEncode + "/devices/sub/uevent": {Mode: 0600, Data: []byte("add")},
+ "checksum/" + wantChecksumEncode + "/devices/uevent": {Mode: 0600, Data: []byte("add")},
"identifier": {Mode: fs.ModeDir | 0700},
- "identifier/iyvWY0irMoW4AVRnG_du07d_nAEUtbHdeiaAYRdp-vf3wstM9u67IokOsPfU9eyM": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/CBPcoVHuVUTVRCMbRl8J30RSSzm_tyfuXaZ-HlZsanY1sY50meOVmgaWDrGKbx9F")},
+ "identifier/iyvWY0irMoW4AVRnG_du07d_nAEUtbHdeiaAYRdp-vf3wstM9u67IokOsPfU9eyM": {Mode: fs.ModeSymlink | 0777, Data: []byte("../checksum/" + wantChecksumEncode)},
"status": {Mode: fs.ModeDir | 0711},
"status/iyvWY0irMoW4AVRnG_du07d_nAEUtbHdeiaAYRdp-vf3wstM9u67IokOsPfU9eyM": {Mode: 0444, Data: []byte(statusHeader + `drwx------ block
diff --git a/pkg/pkg.go b/pkg/pkg.go
index e3d32028..c2fe42fe 100644
--- a/pkg/pkg.go
+++ b/pkg/pkg.go
@@ -2933,6 +2933,9 @@ func Open(
return &c, nil
}
+// Path returns the pathname of the directory holding [Cache] state.
+func (c *Cache) Path() *check.Absolute { return c.base }
+
// Collected is returned by [Collect.Cure] to indicate a successful collection.
type Collected struct{}
diff --git a/pkg/pkg_test.go b/pkg/pkg_test.go
index 2d5fdf8e..66639b5b 100644
--- a/pkg/pkg_test.go
+++ b/pkg/pkg_test.go
@@ -740,6 +740,16 @@ func newWantScrubError(base *check.Absolute) *pkg.ScrubError {
}
}
+// dropFaults removes all fault entries from a cache under testing.
+func dropFaults(t *testing.T, base *check.Absolute) {
+ fault := base.Append("fault")
+ if err := os.RemoveAll(fault.String()); err != nil {
+ t.Fatal(err)
+ } else if err = os.Mkdir(fault.String(), 0); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestCache(t *testing.T) {
t.Parallel()
diff --git a/pkg/tar_test.go b/pkg/tar_test.go
index 58ec3aed..e8a42f73 100644
--- a/pkg/tar_test.go
+++ b/pkg/tar_test.go
@@ -249,10 +249,5 @@ func checkTarHTTP(
}), nil, nil, pkg.WNew, errors.Join(stub.UniqueError(0xcafe))},
})
- fault := base.Append("fault")
- if err := os.RemoveAll(fault.String()); err != nil {
- t.Fatal(err)
- } else if err = os.Mkdir(fault.String(), 0); err != nil {
- t.Fatal(err)
- }
+ dropFaults(t, base)
}