Prove the forge key process over a real bus in the merge check (issue 955 S4)
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Issue 955, area S4. The acceptance: "a machine's account makes its forge key, and the key's public half is
|
||||
// in ssh-client's keys state under <node>.<account>" — proved over a real bus. A JetStream server is linked into
|
||||
// the test and started for it alone; the module is this test binary started again as the bundle, speaking MCP on
|
||||
// its standard streams to a runtime played here, which keeps the state in buckets of the bus as the tool runner
|
||||
// does. It runs in the forge's merge check, not on the way: `go test -short` leaves it out.
|
||||
|
||||
const bundleVar = "MESH_SSH_CLIENT_TEST_BUNDLE"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv(bundleVar) == "1" {
|
||||
main()
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type keysRuntime struct {
|
||||
t *testing.T
|
||||
js jetstream.JetStream
|
||||
in io.WriteCloser
|
||||
mu sync.Mutex
|
||||
buckets map[string]jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (r *keysRuntime) bucket(name string) jetstream.KeyValue {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if kv, ok := r.buckets[name]; ok {
|
||||
return kv
|
||||
}
|
||||
kv, err := r.js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name})
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
r.buckets[name] = kv
|
||||
return kv
|
||||
}
|
||||
|
||||
func (r *keysRuntime) say(m map[string]any) {
|
||||
m["jsonrpc"] = "2.0"
|
||||
raw, _ := json.Marshal(m)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, _ = r.in.Write(append(raw, '\n'))
|
||||
}
|
||||
|
||||
func (r *keysRuntime) read(out io.Reader) {
|
||||
sc := bufio.NewScanner(out)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<24)
|
||||
for sc.Scan() {
|
||||
var m struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if json.Unmarshal(sc.Bytes(), &m) != nil || m.Method == "" {
|
||||
continue
|
||||
}
|
||||
go r.runtime(m.ID, m.Method, m.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// runtime is what the tool runner does for the bundle: only the state this module puts.
|
||||
func (r *keysRuntime) runtime(id json.RawMessage, method string, params json.RawMessage) {
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
_ = json.Unmarshal(params, &p)
|
||||
if method != "mesh/state.put" {
|
||||
r.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": fmt.Sprintf("the test runtime offers no %s", method)}})
|
||||
return
|
||||
}
|
||||
rev, err := r.bucket(p.State).Put(context.Background(), p.Key, p.Value)
|
||||
if err != nil {
|
||||
r.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
r.say(map[string]any{"id": id, "result": map[string]any{"revision": rev}})
|
||||
}
|
||||
|
||||
type keysLog struct{ t *testing.T }
|
||||
|
||||
func (l keysLog) Write(p []byte) (int, error) { l.t.Logf("bundle: %s", p); return len(p), nil }
|
||||
|
||||
func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration test: runs in the forge's merge check")
|
||||
}
|
||||
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("no bus within 30s")
|
||||
}
|
||||
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||
nc, err := nats.Connect(s.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, _ := jetstream.New(nc)
|
||||
r := &keysRuntime{t: t, js: js, buckets: map[string]jetstream.KeyValue{}}
|
||||
|
||||
account, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
path := os.Getenv("PATH")
|
||||
if _, err := exec.LookPath("ssh-keygen"); err != nil {
|
||||
// A build host without OpenSSH: a stand-in that writes the pair the command names.
|
||||
bin := t.TempDir()
|
||||
stub := "#!/bin/sh\nwhile [ $# -gt 0 ]; do case $1 in -f) f=$2;; -C) c=$2;; esac; shift; done\n" +
|
||||
"printf 'PRIVATE\\n' > \"$f\"; chmod 600 \"$f\"\n" +
|
||||
"printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA %s\\n' \"$c\" > \"$f.pub\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "ssh-keygen"), []byte(stub), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path = bin + string(os.PathListSeparator) + path
|
||||
t.Log("no ssh-keygen on this host: using a stand-in")
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), bundleVar+"=1", "PATH="+path, "MESH_OPERATOR_HOME="+home,
|
||||
"MESH_SERVED_MODULE=ssh-client", "MESH_MODULE=ssh-client", "MESH_NODE=test")
|
||||
in, _ := cmd.StdinPipe()
|
||||
out, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = keysLog{t}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.in = in
|
||||
go r.read(out)
|
||||
t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() })
|
||||
|
||||
key := "test." + account.Username
|
||||
var raw []byte
|
||||
for deadline := time.Now().Add(60 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||
if e, err := r.bucket("keys").Get(context.Background(), key); err == nil {
|
||||
raw = e.Value()
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
t.Fatalf("no entry %s in the keys state within 60 s", key)
|
||||
}
|
||||
var e KeyEntry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(e.Public, "ssh-ed25519 ") || e.Node != "test" || e.Account != account.Username || e.Kind != "machine" || !strings.HasPrefix(e.Fingerprint, "SHA256:") {
|
||||
t.Errorf("entry = %s", raw)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||
t.Errorf("the entry carries private material: %s", raw)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(home, ".ssh", KeyFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the private key is %04o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
module sshclient
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.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.18.5 // 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.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
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.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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