872ffcaf25
P07a/b/c — Runtime abstraction interface + 5 implementations.
Runtime interface (internal/runtime/runtime.go, REQ-078):
- Runtime interface { Prepare, Start, Stop, Status }. Alloc struct carries
Runtime field (changeable on migration per R-004). Registry keyed by
runtime.one_of. DefaultRegistry(transport) registers all 5.
Process (internal/runtime/process.go):
- ProcessRuntime wraps os/exec (LOCAL testing only; production uses systemd
emitter). SIGTERM grace 10s then SIGKILL.
Podman (internal/runtime/podman.go):
- PodmanRuntime via sshpush.Transport. podman pull/run/stop/rm/inspect.
Wasm (internal/runtime/wasm.go, gate C-01 SATISFIED):
- WasmRuntime uses wasmtime CLI (apt-installed on peer) via SSH exec. NO CGO
— does NOT import bytecodealliance/wasmtime-go. CGO_ENABLED=0 build
passes. D-002 cross-compile story preserved. D-187 recorded.
PVE (internal/runtime/pve.go):
- PveVMRuntime (qm create/start/stop/status) + PveCTRuntime (pct
create/start/stop/status) via sshpush.Transport. VMID = hash(alloc.ID)%99999.
C-01 evaluation: internal/runtime/C01_WASMTIME_CGO_EVAL.md. Auto-decision
(full autonomy): wasmtime remains primary; CLI-via-SSH avoids CGO entirely.
D-187 in PROJECT.md.
23 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.
92.7% coverage on internal/runtime.
---ci---
project: orca
phase: P07a/b/c
milestone: v0.9
status: execute
---/ci---
335 lines
8.3 KiB
Go
335 lines
8.3 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
|
)
|
|
|
|
// --- fake SSH server for runtime tests (mirrors sshpush/transport_test.go) ---
|
|
|
|
type fakeServer struct {
|
|
listener net.Listener
|
|
config *ssh.ServerConfig
|
|
done chan struct{}
|
|
hostKey ssh.Signer
|
|
|
|
mu sync.Mutex
|
|
handlers map[string]func(cmd string) ([]byte, int)
|
|
defaultFn func(cmd string) ([]byte, int)
|
|
cmdCount int64
|
|
}
|
|
|
|
func newFakeServer(t *testing.T) *fakeServer {
|
|
t.Helper()
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ed25519 gen: %v", err)
|
|
}
|
|
signer, err := ssh.NewSignerFromKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("ssh signer: %v", err)
|
|
}
|
|
config := &ssh.ServerConfig{NoClientAuth: true}
|
|
config.AddHostKey(signer)
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
srv := &fakeServer{
|
|
listener: ln,
|
|
config: config,
|
|
done: make(chan struct{}),
|
|
hostKey: signer,
|
|
handlers: make(map[string]func(cmd string) ([]byte, int)),
|
|
defaultFn: func(cmd string) ([]byte, int) {
|
|
return []byte("sh: command not found\n"), 127
|
|
},
|
|
}
|
|
go srv.serve()
|
|
return srv
|
|
}
|
|
|
|
func (s *fakeServer) addr() string { return s.listener.Addr().String() }
|
|
func (s *fakeServer) hostPublicKey() ssh.PublicKey { return s.hostKey.PublicKey() }
|
|
|
|
func (s *fakeServer) close() {
|
|
_ = s.listener.Close()
|
|
<-s.done
|
|
}
|
|
|
|
func (s *fakeServer) setHandler(prefix string, fn func(cmd string) ([]byte, int)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.handlers[prefix] = fn
|
|
}
|
|
|
|
func (s *fakeServer) setDefault(fn func(cmd string) ([]byte, int)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.defaultFn = fn
|
|
}
|
|
|
|
func (s *fakeServer) count() int64 { return atomic.LoadInt64(&s.cmdCount) }
|
|
|
|
func (s *fakeServer) serve() {
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
close(s.done)
|
|
return
|
|
}
|
|
go s.handle(conn)
|
|
}
|
|
}
|
|
|
|
func (s *fakeServer) handle(netConn net.Conn) {
|
|
defer netConn.Close()
|
|
_, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
|
|
if err != nil {
|
|
return
|
|
}
|
|
go ssh.DiscardRequests(reqs)
|
|
for newChan := range chans {
|
|
if newChan.ChannelType() != "session" {
|
|
newChan.Reject(ssh.UnknownChannelType, "only session")
|
|
continue
|
|
}
|
|
go s.handleSession(newChan)
|
|
}
|
|
}
|
|
|
|
func (s *fakeServer) handleSession(newChan ssh.NewChannel) {
|
|
ch, reqs, err := newChan.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer ch.Close()
|
|
for req := range reqs {
|
|
if req.Type != "exec" {
|
|
req.Reply(false, nil)
|
|
continue
|
|
}
|
|
var execReq struct{ Command string }
|
|
if err := ssh.Unmarshal(req.Payload, &execReq); err != nil {
|
|
req.Reply(false, nil)
|
|
continue
|
|
}
|
|
req.Reply(true, nil)
|
|
atomic.AddInt64(&s.cmdCount, 1)
|
|
out, code := s.runCommand(execReq.Command)
|
|
_, _ = ch.Write(out)
|
|
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
|
|
_ = ch.Close()
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *fakeServer) runCommand(cmd string) ([]byte, int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
trimmed := strings.TrimSpace(cmd)
|
|
for prefix, fn := range s.handlers {
|
|
if strings.HasPrefix(trimmed, prefix) {
|
|
return fn(trimmed)
|
|
}
|
|
}
|
|
return s.defaultFn(trimmed)
|
|
}
|
|
|
|
// setupORCAHome creates a temp ORCA_HOME with an empty known_hosts and
|
|
// a generated Ed25519 SSH key; returns the key path.
|
|
func setupORCAHome(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("ORCA_HOME", dir)
|
|
knownHosts := filepath.Join(dir, "known_hosts")
|
|
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
|
|
t.Fatalf("create known_hosts: %v", err)
|
|
}
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ed25519 gen: %v", err)
|
|
}
|
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("marshal key: %v", err)
|
|
}
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
|
keyPath := filepath.Join(dir, "orca_ssh_key")
|
|
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
|
|
t.Fatalf("write key: %v", err)
|
|
}
|
|
return keyPath
|
|
}
|
|
|
|
// realTransport wires a *sshpush.Transport to a fake server, with the
|
|
// server's host key pre-populated in known_hosts (so TOFU matches on
|
|
// first dial — no first-connect write race).
|
|
func realTransport(t *testing.T, srv *fakeServer) *sshpush.Transport {
|
|
t.Helper()
|
|
keyPath := setupORCAHome(t)
|
|
tr := sshpush.NewTransport(keyPath, "")
|
|
tr.SetUser("root")
|
|
addr := srv.addr()
|
|
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, srv.hostPublicKey())
|
|
home := os.Getenv("ORCA_HOME")
|
|
kh := filepath.Join(home, "known_hosts")
|
|
if err := os.WriteFile(kh, []byte(line+"\n"), 0o600); err != nil {
|
|
t.Fatalf("pre-pop known_hosts: %v", err)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
// alloc builds a minimal Alloc for tests.
|
|
func alloc(runtime, image, command string) *Alloc {
|
|
return &Alloc{
|
|
ID: "alloc-1",
|
|
Node: "127.0.0.1:0",
|
|
Runtime: runtime,
|
|
Spec: &jobspec.WorkloadSpec{
|
|
Name: "test",
|
|
Runtime: &jobspec.RuntimeBlock{
|
|
OneOf: runtime,
|
|
Image: image,
|
|
Command: command,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// allocWithNode returns an alloc bound to the given peer address.
|
|
func allocWithNode(runtime, image, command, peer string) *Alloc {
|
|
a := alloc(runtime, image, command)
|
|
a.Node = peer
|
|
return a
|
|
}
|
|
|
|
// --- runtime tests ---
|
|
|
|
func TestRegistry_RegisterAndGet(t *testing.T) {
|
|
r := NewRegistry()
|
|
r.Register("process", NewProcessRuntime())
|
|
rt, err := r.Get("process")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if rt == nil {
|
|
t.Fatal("nil runtime")
|
|
}
|
|
|
|
if _, err := r.Get("nope"); err == nil {
|
|
t.Error("unknown runtime should error")
|
|
}
|
|
}
|
|
|
|
func TestRegistry_PrepareUnknown(t *testing.T) {
|
|
r := NewRegistry()
|
|
a := alloc("nonexistent", "", "/bin/true")
|
|
if err := r.Prepare(context.Background(), a); err == nil {
|
|
t.Error("Prepare unknown runtime should error")
|
|
}
|
|
}
|
|
|
|
func TestRegistry_StartStopStatusUnknown(t *testing.T) {
|
|
r := NewRegistry()
|
|
a := alloc("nonexistent", "", "/bin/true")
|
|
if _, err := r.Start(context.Background(), a); err == nil {
|
|
t.Error("Start unknown should error")
|
|
}
|
|
if err := r.Stop(context.Background(), a); err == nil {
|
|
t.Error("Stop unknown should error")
|
|
}
|
|
if _, err := r.Status(context.Background(), a); err == nil {
|
|
t.Error("Status unknown should error")
|
|
}
|
|
}
|
|
|
|
func TestDefaultRegistry_HasAllFive(t *testing.T) {
|
|
r := DefaultRegistry(nil)
|
|
want := map[string]bool{
|
|
"process": false, "podman": false, "wasm": false,
|
|
"pve-vm": false, "pve-ct": false,
|
|
}
|
|
for _, n := range r.Names() {
|
|
if _, ok := want[n]; ok {
|
|
want[n] = true
|
|
}
|
|
}
|
|
for k, v := range want {
|
|
if !v {
|
|
t.Errorf("DefaultRegistry missing %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewRegistry_EmptyGetError(t *testing.T) {
|
|
r := NewRegistry()
|
|
if _, err := r.Get("anything"); err == nil {
|
|
t.Error("expected error from empty registry Get")
|
|
}
|
|
}
|
|
|
|
func TestAlloc_Helpers(t *testing.T) {
|
|
if _, err := commandFor(nil); err == nil {
|
|
t.Error("commandFor(nil) should error")
|
|
}
|
|
if _, err := imageFor(nil); err == nil {
|
|
t.Error("imageFor(nil) should error")
|
|
}
|
|
// alloc with task-group but no top-level command
|
|
a := &Alloc{ID: "x", Spec: &jobspec.WorkloadSpec{
|
|
Tasks: []jobspec.TaskGroupTask{{Command: "/bin/true"}},
|
|
}}
|
|
cmd, err := commandFor(a)
|
|
if err != nil {
|
|
t.Fatalf("commandFor task group: %v", err)
|
|
}
|
|
if cmd != "/bin/true" {
|
|
t.Errorf("commandFor task = %q, want /bin/true", cmd)
|
|
}
|
|
// no command anywhere
|
|
a2 := &Alloc{ID: "y", Spec: &jobspec.WorkloadSpec{}}
|
|
if _, err := commandFor(a2); err == nil {
|
|
t.Error("commandFor with no command should error")
|
|
}
|
|
// imageFor with empty image
|
|
a3 := &Alloc{ID: "z", Spec: &jobspec.WorkloadSpec{Runtime: &jobspec.RuntimeBlock{}}}
|
|
if _, err := imageFor(a3); err == nil {
|
|
t.Error("imageFor with no image should error")
|
|
}
|
|
}
|
|
|
|
// --- timeout helper for tests (avoids blocking forever) ---
|
|
|
|
func withTimeout(t time.Duration) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), t)
|
|
}
|
|
|
|
// compile-time interface conformance checks.
|
|
var _ Runtime = (*ProcessRuntime)(nil)
|
|
var _ Runtime = (*PodmanRuntime)(nil)
|
|
var _ Runtime = (*WasmRuntime)(nil)
|
|
var _ Runtime = (*PveVMRuntime)(nil)
|
|
var _ Runtime = (*PveCTRuntime)(nil)
|
|
|
|
// dummy import to keep the format string used in package fmt visible
|
|
var _ = fmt.Sprintf
|