Fail a push whose digest file cannot be read; test the image steps through a real bus against captured shapes (issue 1703)
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// ADR 0487 §1: the build step answers the image's digest, through a real bus (issue 1703e). A nats-server from the Go
|
||||
// library, at the version this module's go.mod names, runs with JetStream on in the test's directory (the pattern of
|
||||
// mesh-build-go's bus.go). The module's steps state is a key-value bucket on that bus, and its tools are served on bus
|
||||
// subjects by a small bridge in the test, so a call, the step that runs behind it and the key it ends on all travel over
|
||||
// the bus; the mesh's own tool runner is not started here. buildah and git are the fake runner's, so this runs wherever
|
||||
// the check runs; the real buildah is TestARealBuild... in integration_test.go. What the keys hold is compared with the
|
||||
// captured shapes (testdata/captured, capture_test.go) that the builder's tests copy.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// bus starts the throwaway server and answers a connection to it.
|
||||
func bus(t *testing.T) *nats.Conn {
|
||||
t.Helper()
|
||||
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
t.Fatal("the bus was not ready")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
nc, err := nats.Connect(s.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
|
||||
// onBus wires a module's steps state to a bucket and serves its tools on "test.tool.<name>": the request body is the
|
||||
// arguments, the answer is {"result": ...} or {"error": "..."}.
|
||||
func onBus(t *testing.T, nc *nats.Conn, m *Module) jetstream.KeyValue {
|
||||
t.Helper()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-build-image-steps"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Steps = func(key string, value any) (uint64, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return kv.Put(ctx, key, raw)
|
||||
}
|
||||
for _, tl := range m.Tools(m.StepTools()) {
|
||||
tl := tl
|
||||
if _, err := nc.Subscribe("test.tool."+tl.Name, func(msg *nats.Msg) {
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(msg.Data, &args)
|
||||
res, err := tl.Run(args)
|
||||
out := map[string]any{"result": res}
|
||||
if err != nil {
|
||||
out = map[string]any{"error": err.Error()}
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
_ = msg.Respond(b)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// call calls a tool over the bus and answers its result, which for a long step is the running step's key.
|
||||
func call(t *testing.T, nc *nats.Conn, tool string, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(args)
|
||||
msg, err := nc.Request("test.tool."+tool, body, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out struct {
|
||||
Result map[string]any `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &out); err != nil || out.Error != "" {
|
||||
t.Fatalf("%s answered %s (%v)", tool, msg.Data, err)
|
||||
}
|
||||
return out.Result
|
||||
}
|
||||
|
||||
// ended waits for the step's key in the bucket to leave running and answers the step as the bucket holds it.
|
||||
func ended(t *testing.T, kv jetstream.KeyValue, key string) delivery.Step {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if e, err := kv.Get(context.Background(), key); err == nil {
|
||||
st, err := delivery.DecodeStep(e.Value())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State != delivery.StepRunning {
|
||||
return st
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("the key %s never left running", key)
|
||||
return delivery.Step{}
|
||||
}
|
||||
|
||||
func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) {
|
||||
nc := bus(t)
|
||||
f := newFake(t)
|
||||
m := moduleWith(t, f, newStates())
|
||||
kv := onBus(t, nc, m)
|
||||
place(t, m, same)
|
||||
|
||||
// The fetch: a running key at once, then the workspace on the key.
|
||||
started := call(t, nc, FetchTool, map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA})
|
||||
if started["state"] != delivery.StepRunning || started["key"] == "" {
|
||||
t.Fatalf("the fetch answered %v, want a running step and its key", started)
|
||||
}
|
||||
st := ended(t, kv, started["key"].(string))
|
||||
compareCaptured(t, "fetch.json", normalized(t, st, workspaceOf(t, st), commitSHA, ""))
|
||||
|
||||
// The build of a pull request: the image's digest, nothing pushed. The workspace is the fixture's, "ws1".
|
||||
for name, extra := range map[string]map[string]any{"build-candidate.json": nil, "build-trunk.json": {"publish": true}} {
|
||||
args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"}
|
||||
for k, v := range extra {
|
||||
args[k] = v
|
||||
}
|
||||
started := call(t, nc, BuildTool, args)
|
||||
st := ended(t, kv, started["key"].(string))
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("%s: step = %+v", name, st)
|
||||
}
|
||||
var res BuildResult
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil || !digestOK.MatchString(res.Digest) || res.Pushed != (extra != nil) {
|
||||
t.Errorf("%s: result = %s (%v), want the image's digest and pushed %v", name, st.Result, err, extra != nil)
|
||||
}
|
||||
compareCaptured(t, name, normalized(t, st, "", "", f.address()))
|
||||
}
|
||||
if len(f.registry) != 1 || f.pushes != 1 {
|
||||
t.Errorf("the registry holds %v after %d pushes, want only the trunk build's one", f.registry, f.pushes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
// The shapes of what the module's steps end on in the steps state (novox/hq ADR 0487 §1, issue 1703e), kept as fixtures in
|
||||
// testdata/captured for the builder's tests (mesh-delivery-builder copies them): the stored step of a fetch, of a
|
||||
// candidate's build (no publish) and of a trunk build (publish). They are the step exactly as buildstep writes it to the
|
||||
// state, with what differs between runs (ids, times, the workspace, the commit, digests, the registry's address) put
|
||||
// to fixed words. This unit test regenerates them from the fake runner and compares (MESH_UPDATE_CAPTURED=1 rewrites
|
||||
// them); the integration test through the real bus (bus_integration_test.go) compares the steps the real bus holds with
|
||||
// the same files, so a change of shape fails in both places.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const capturedDir = "testdata/captured"
|
||||
|
||||
var hexDigest = regexp.MustCompile(`sha256:[0-9a-f]{64}`)
|
||||
|
||||
// normalized is a stored step with the words that differ between runs put to fixed ones.
|
||||
func normalized(t *testing.T, st delivery.Step, workspace, commit, registry string) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v["id"] = "STEPID"
|
||||
v["started"], v["ended"] = "2026-10-22T08:00:00Z", "2026-10-22T08:00:01Z"
|
||||
out, _ := json.MarshalIndent(v, "", " ")
|
||||
s := string(out)
|
||||
for from, to := range map[string]string{workspace: "WORKSPACE", commit: "COMMIT", registry: "REGISTRY"} {
|
||||
if from != "" {
|
||||
s = strings.ReplaceAll(s, from, to)
|
||||
}
|
||||
}
|
||||
s = hexDigest.ReplaceAllString(s, imageDigest)
|
||||
// The result is stored as a JSON string of an object in some encodings: keep the file readable by decoding it once.
|
||||
var back map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(back, "", " ")
|
||||
return append(pretty, '\n')
|
||||
}
|
||||
|
||||
func compareCaptured(t *testing.T, name string, got []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join(capturedDir, name)
|
||||
if os.Getenv("MESH_UPDATE_CAPTURED") == "1" {
|
||||
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v (MESH_UPDATE_CAPTURED=1 writes it)", path, err)
|
||||
}
|
||||
if string(want) != string(got) {
|
||||
t.Errorf("%s differs from what the step ended on:\n--- want\n%s--- got\n%s", name, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceOf(t *testing.T, st delivery.Step) string {
|
||||
var r struct {
|
||||
Workspace string `json:"workspace"`
|
||||
}
|
||||
json.Unmarshal(st.Result, &r)
|
||||
return r.Workspace
|
||||
}
|
||||
|
||||
func TestTheStoredStepsHaveTheCapturedShapes(t *testing.T) {
|
||||
_ = time.Now
|
||||
// fetch
|
||||
{
|
||||
f := newFake(t)
|
||||
states := newStates()
|
||||
m := moduleWith(t, f, states)
|
||||
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
compareCaptured(t, "fetch.json", normalized(t, st, workspaceOf(t, st), commitSHA, ""))
|
||||
}
|
||||
// a candidate's build and a trunk build
|
||||
for name, extra := range map[string]map[string]any{"build-candidate.json": nil, "build-trunk.json": {"publish": true}} {
|
||||
m, f, states := setup(t, same)
|
||||
_, st := runBuild(t, m, states, extra)
|
||||
compareCaptured(t, name, normalized(t, st, "", "", f.address()))
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@ type fake struct {
|
||||
pushes int
|
||||
refuse int // the first n pushes answer "connection refused"
|
||||
failBud string
|
||||
// digestFile: "" writes the digest, "none" writes no file, "empty" writes an empty one.
|
||||
digestFile string
|
||||
// registry: repository:tag -> digest
|
||||
registry map[string]string
|
||||
server *httptest.Server
|
||||
@@ -138,7 +140,12 @@ func (f *fake) run(ctx context.Context, env []string, name string, args ...strin
|
||||
}
|
||||
dest = strings.TrimPrefix(dest, f.address()+"/")
|
||||
f.registry[dest] = f.digest
|
||||
os.WriteFile(digestFile, []byte(f.digest), 0o644)
|
||||
switch f.digestFile {
|
||||
case "":
|
||||
os.WriteFile(digestFile, []byte(f.digest), 0o644)
|
||||
case "empty":
|
||||
os.WriteFile(digestFile, nil, 0o644)
|
||||
}
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,18 @@ func (m *Module) push(ctx context.Context, workspace, module, local string, res
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("pushed %s and cannot read back what the registry serves for it: %w", dest, err)
|
||||
}
|
||||
if raw, err := os.ReadFile(digestFile); err == nil {
|
||||
if pushed := strings.TrimSpace(string(raw)); pushed != "" && pushed != served {
|
||||
return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served)
|
||||
}
|
||||
// A push that succeeded wrote the digest it made: a file missing, unreadable or empty is a fault, and the registry's
|
||||
// digest is never answered unchecked.
|
||||
raw, err := os.ReadFile(digestFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pushed %s, but the digest buildah pushed cannot be read from %s, so the registry's %s cannot be checked: %w", dest, digestFile, served, err)
|
||||
}
|
||||
pushed := strings.TrimSpace(string(raw))
|
||||
if !digestOK.MatchString(pushed) {
|
||||
return nil, fmt.Errorf("pushed %s, but the digest file %s holds %q, not a digest, so the registry's %s cannot be checked", dest, digestFile, pushed, served)
|
||||
}
|
||||
if pushed != served {
|
||||
return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served)
|
||||
}
|
||||
res.Digest, res.Pushed = served, true
|
||||
res.Reference = addr + "/" + repository + "@" + served
|
||||
|
||||
@@ -141,3 +141,16 @@ func writeFile(t *testing.T, path, body string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A push that succeeded wrote its digest file: one missing, unreadable or empty is a fault, and the registry's digest is
|
||||
// never answered unchecked (review round 1).
|
||||
func TestAPushWithoutAReadableDigestFileFailsTheStep(t *testing.T) {
|
||||
for _, kind := range []string{"none", "empty"} {
|
||||
m, f, states := setup(t, same)
|
||||
f.digestFile = kind
|
||||
_, st := runBuild(t, m, states, map[string]any{"publish": true})
|
||||
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "digest") {
|
||||
t.Errorf("%s: step = %+v, want failed naming the digest file", kind, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
The stored steps (what a step ends on in the module's `steps` state) of a fetch (fetch.json), a candidate's build
|
||||
(build-candidate.json, no publish) and a trunk build (build-trunk.json, publish), as buildstep writes them, with ids, times,
|
||||
the workspace, the commit, digests and the registry's address put to fixed words (STEPID, WORKSPACE, COMMIT, REGISTRY, one
|
||||
sha256 digest). Written by capture_test.go (MESH_UPDATE_CAPTURED=1) from the unit tests' fake runner on 2026-10-22, and
|
||||
compared by bus_integration_test.go with the steps a real bus holds. mesh-delivery-builder's tests copy these files.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"ended": "2026-10-22T08:00:01Z",
|
||||
"id": "STEPID",
|
||||
"node": "ace",
|
||||
"result": {
|
||||
"artifact": "server",
|
||||
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"pushed": false
|
||||
},
|
||||
"started": "2026-10-22T08:00:00Z",
|
||||
"state": "passed",
|
||||
"tool": "build_image_build",
|
||||
"v": 1
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"ended": "2026-10-22T08:00:01Z",
|
||||
"id": "STEPID",
|
||||
"node": "ace",
|
||||
"result": {
|
||||
"artifact": "server",
|
||||
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"pushed": true,
|
||||
"reference": "REGISTRY/fixture-image/server@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
},
|
||||
"started": "2026-10-22T08:00:00Z",
|
||||
"state": "passed",
|
||||
"tool": "build_image_build",
|
||||
"v": 1
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ended": "2026-10-22T08:00:01Z",
|
||||
"id": "STEPID",
|
||||
"node": "ace",
|
||||
"result": {
|
||||
"commit": "COMMIT",
|
||||
"workspace": "WORKSPACE"
|
||||
},
|
||||
"started": "2026-10-22T08:00:00Z",
|
||||
"state": "passed",
|
||||
"tool": "build_image_fetch",
|
||||
"v": 1
|
||||
}
|
||||
@@ -2,4 +2,21 @@ module meshbuildimage
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
Reference in New Issue
Block a user