diff options
| -rw-r--r-- | .gitea/README | 5 | ||||
| -rw-r--r-- | .gitea/workflows/release.yml | 25 | ||||
| -rw-r--r-- | .gitea/workflows/test.yml | 130 | ||||
| -rw-r--r-- | cmd/mbf/internal/ci/ci.go | 522 | ||||
| -rw-r--r-- | cmd/mbf/internal/ci/ci_test.go | 55 | ||||
| -rw-r--r-- | cmd/mbf/main.go | 37 | ||||
| -rw-r--r-- | internal/rosa/llvm_test.go | 2 | ||||
| -rw-r--r-- | internal/rosa/mirror.go | 1 | ||||
| -rw-r--r-- | internal/rosa/package/gnu.az | 18 | ||||
| -rw-r--r-- | internal/rosa/package/gzip/late-include.patch | 21 | ||||
| -rw-r--r-- | internal/rosa/package/gzip/package.az | 18 | ||||
| -rw-r--r-- | internal/workflows/doc.go | 10 | ||||
| -rw-r--r-- | internal/workflows/main.go | 149 | ||||
| -rw-r--r-- | internal/workflows/release.go | 37 | ||||
| -rw-r--r-- | internal/workflows/test.go | 227 | ||||
| -rw-r--r-- | pkg/archive.go | 67 | ||||
| -rw-r--r-- | pkg/archive_test.go | 53 | ||||
| -rw-r--r-- | pkg/pkg.go | 3 | ||||
| -rw-r--r-- | pkg/pkg_test.go | 10 | ||||
| -rw-r--r-- | pkg/tar_test.go | 7 |
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 @@ -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) } |
