mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 06:04:55 -04:00
GET /api/cluster/peer authenticated with the deployment's shared registration token and took the dialling replica's id from ?id= on trust. Every worker holds that token, so anything holding it could open a peer link as any replica: relay through it to every worker tunnel that replica owns, displace a real replica's inbound link by declaring its id, and point the roughly 31 GiB per-session receive window at one replica. Validating the id against the instances table does not fix this, because the attack declares a real replica's id. So the route now checks two credentials and needs both. The shared token still says the dialler belongs to this deployment; a new per-replica credential says which replica it is. The credential follows the per-node worker credential rather than inventing a second mechanism: crypto/rand.Text, stored only as a hex SHA-256, compared in constant time, with no fallback to the shared token. It differs in the stronger direction. A worker's credential is minted by the frontend and handed over once; a replica writes its own instances row, so it mints its own secret, publishes only the hash in the same statement that publishes its address, and never sends the plaintext anywhere but the peer dial. A peer that presents no credential is refused, not waved through. An old replica and an attacker holding the shared token send the same request, so accepting the first accepts the second; there is no safe downgrade here, only a quiet one. The refusal is made loud instead, on both sides, naming the upgrade rather than the network. On the documented frontend-first order a new replica still dials an old one; an old replica cannot dial a new one, which costs relayed requests that land on a not-yet-restarted replica and surfaces as no route, never as absence. A rejected peer gets its own sentinel, ErrPeerRejected, whose unwrap chain carries ErrPeerUnreachable as well and no absence sentinel at all. Keeping the older sentinel means no existing consumer changes behaviour; the cause stays out of the chain, so absence cannot escape through it and nothing can read an authorization failure as a worker that went away. One consequence beyond the fix: a replica with no advertised address has no instances row, so it now cannot dial out either. It was already unreachable inward. The startup error and the docs say so. Registry.Register, NewMembership, NewPeerPool, PeerHandler and RegisterClusterRoutes all gained required arguments, so the identity cannot be dropped without a compile failure. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
278 lines
9.9 KiB
Go
278 lines
9.9 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/services/advisorylock"
|
|
"github.com/mudler/LocalAI/core/services/cluster"
|
|
"github.com/mudler/LocalAI/core/services/jobs"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var _ = Describe("Job Dispatch", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
store *jobs.JobStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_dispatch_test")
|
|
|
|
var err error
|
|
db, err = gorm.Open(pgdriver.Open(infra.PGURL), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
store, err = jobs.NewJobStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("Claim-queue dispatch", func() {
|
|
// The whole path, against a real PostgreSQL: Enqueue writes a claim
|
|
// row, a frontend replica takes it with SELECT ... FOR UPDATE SKIP
|
|
// LOCKED, drives it as a streaming control RPC, and persists the
|
|
// worker's terminal line before it releases the claim.
|
|
It("enqueues a claim, drives it on a worker, and persists what the worker answered", func() {
|
|
Expect(cluster.Migrate(infra.Ctx, db)).To(Succeed())
|
|
const owner = "dispatch-instance"
|
|
// A replica that is not registered may not claim: its claims could
|
|
// not be told from ones a dead replica left.
|
|
Expect(cluster.NewRegistry(db).Register(infra.Ctx, owner, "127.0.0.1:8080", "v1", "")).To(Succeed())
|
|
|
|
dispatcher := jobs.NewDispatcher(store, infra.Bus(), db, owner)
|
|
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "dispatch-task", Model: "m1", Prompt: "p1"}
|
|
store.CreateTask(task)
|
|
job := &jobs.JobRecord{TaskID: task.ID, UserID: "u1", Status: "pending", TriggeredBy: "api"}
|
|
store.CreateJob(job)
|
|
|
|
Expect(dispatcher.Enqueue(job.ID, task.ID, "u1")).To(Succeed())
|
|
|
|
var claimed int64
|
|
Expect(db.Model(&jobs.WorkClaim{}).Count(&claimed).Error).To(Succeed())
|
|
Expect(claimed).To(Equal(int64(1)), "the enqueue must leave a row, not a publish nobody may be listening for")
|
|
|
|
worker := &scriptedWorker{reply: jobs.ClaimReply{JobID: job.ID, Status: "completed", Result: "done"}}
|
|
loop, err := jobs.NewDispatchLoop(jobs.DispatchConfig{
|
|
DB: db,
|
|
Owner: owner,
|
|
Selector: fixedAgent{},
|
|
Control: worker,
|
|
Store: store,
|
|
Liveness: time.Minute,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// The claim kind is "task" here (no MCP servers on the model), and
|
|
// no worker serves it, so the loop closes the job out with a reason
|
|
// rather than leaving it running for ever. Give the row a kind a
|
|
// worker DOES serve, so this spec exercises the dispatch path.
|
|
Expect(db.Model(&jobs.WorkClaim{}).Where("kind = ?", string(jobs.ClaimKindTask)).
|
|
Update("kind", string(jobs.ClaimKindMCPCI)).Error).To(Succeed())
|
|
|
|
Expect(loop.DispatchOnce(infra.Ctx)).To(Succeed())
|
|
|
|
Expect(worker.calls.Load()).To(Equal(int32(1)))
|
|
updated, _ := store.GetJob(job.ID)
|
|
Expect(updated.Status).To(Equal("completed"))
|
|
Expect(updated.Result).To(Equal("done"))
|
|
|
|
Expect(db.Model(&jobs.WorkClaim{}).Count(&claimed).Error).To(Succeed())
|
|
Expect(claimed).To(BeZero(), "an answered claim must not be able to run again")
|
|
})
|
|
|
|
It("leaves a plain task job failed with a reason, since no worker in this deployment serves that kind", func() {
|
|
Expect(cluster.Migrate(infra.Ctx, db)).To(Succeed())
|
|
const owner = "plain-instance"
|
|
Expect(cluster.NewRegistry(db).Register(infra.Ctx, owner, "127.0.0.1:8081", "v1", "")).To(Succeed())
|
|
|
|
dispatcher := jobs.NewDispatcher(store, infra.Bus(), db, owner)
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "plain-task", Model: "m1", Prompt: "p1"}
|
|
Expect(store.CreateTask(task)).To(Succeed())
|
|
job := &jobs.JobRecord{TaskID: task.ID, UserID: "u1", Status: "pending", TriggeredBy: "api"}
|
|
Expect(store.CreateJob(job)).To(Succeed())
|
|
Expect(dispatcher.Enqueue(job.ID, task.ID, "u1")).To(Succeed())
|
|
|
|
worker := &scriptedWorker{}
|
|
loop, err := jobs.NewDispatchLoop(jobs.DispatchConfig{
|
|
DB: db, Owner: owner, Selector: fixedAgent{}, Control: worker,
|
|
Store: store, Liveness: time.Minute,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(loop.DispatchOnce(infra.Ctx)).To(Succeed())
|
|
|
|
Expect(worker.calls.Load()).To(BeZero(), "nothing serves this kind, so nothing may be asked of a worker")
|
|
updated, _ := store.GetJob(job.ID)
|
|
Expect(updated.Status).To(Equal("failed"))
|
|
Expect(updated.Error).To(ContainSubstring("plain task jobs"))
|
|
})
|
|
})
|
|
|
|
Context("PostgreSQL job persistence", func() {
|
|
It("should persist job state in PostgreSQL via JobStore", func() {
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "persist-task", Model: "m1", Prompt: "run something"}
|
|
Expect(store.CreateTask(task)).To(Succeed())
|
|
Expect(task.ID).ToNot(BeEmpty())
|
|
|
|
job := &jobs.JobRecord{
|
|
TaskID: task.ID,
|
|
UserID: "u1",
|
|
Status: "pending",
|
|
TriggeredBy: "api",
|
|
}
|
|
Expect(store.CreateJob(job)).To(Succeed())
|
|
Expect(job.ID).ToNot(BeEmpty())
|
|
|
|
// Verify retrieval
|
|
retrieved, err := store.GetJob(job.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.TaskID).To(Equal(task.ID))
|
|
Expect(retrieved.Status).To(Equal("pending"))
|
|
|
|
// Update status
|
|
Expect(store.UpdateJobStatus(job.ID, "running", "", "")).To(Succeed())
|
|
running, _ := store.GetJob(job.ID)
|
|
Expect(running.Status).To(Equal("running"))
|
|
Expect(running.StartedAt).ToNot(BeNil())
|
|
|
|
// Complete
|
|
Expect(store.UpdateJobStatus(job.ID, "completed", "output data", "")).To(Succeed())
|
|
completed, _ := store.GetJob(job.ID)
|
|
Expect(completed.Status).To(Equal("completed"))
|
|
Expect(completed.Result).To(Equal("output data"))
|
|
Expect(completed.CompletedAt).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Context("job cancellation", func() {
|
|
// Cancellation stays a BROADCAST and is not part of the claim queue: the
|
|
// replica holding a run is not the one an API cancel lands on, so the
|
|
// signal has to reach every replica and every worker.
|
|
//
|
|
// Asserted across TWO carriers, because one carrier hearing itself
|
|
// proves nothing about the replica that actually holds the execution.
|
|
// A cancel that does not arrive is not a cancel that was refused, so
|
|
// what is pinned here is arrival and never the publisher's error.
|
|
It("broadcasts a cancel for a job on the job's own cancel subject", func() {
|
|
publisher, listener := infra.Bus(), infra.Bus()
|
|
dispatcher := jobs.NewDispatcher(store, publisher, db, "cancel-instance")
|
|
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "cancel-task", Model: "m1", Prompt: "p1"}
|
|
store.CreateTask(task)
|
|
job := &jobs.JobRecord{TaskID: task.ID, UserID: "u1", Status: "pending", TriggeredBy: "api"}
|
|
store.CreateJob(job)
|
|
|
|
seen := make(chan string, 1)
|
|
sub, err := listener.Subscribe(messaging.SubjectJobCancelWildcard, func(data []byte) {
|
|
var evt jobs.CancelEvent
|
|
if json.Unmarshal(data, &evt) == nil {
|
|
select {
|
|
case seen <- evt.JobID:
|
|
default:
|
|
}
|
|
}
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = sub.Unsubscribe() }()
|
|
|
|
Expect(dispatcher.Cancel(job.ID)).To(Succeed())
|
|
Eventually(seen, "10s").Should(Receive(Equal(job.ID)))
|
|
})
|
|
})
|
|
|
|
Context("Cron leader election", func() {
|
|
It("should elect one cron leader via advisory lock", func() {
|
|
sqlDB, err := db.DB()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
conn1, err := sqlDB.Conn(context.Background())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn1.Close()
|
|
|
|
conn2, err := sqlDB.Conn(context.Background())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn2.Close()
|
|
|
|
// Instance 1 acquires the cron leader lock
|
|
var acquired1 bool
|
|
conn1.QueryRowContext(context.Background(),
|
|
"SELECT pg_try_advisory_lock($1)", advisorylock.KeyCronScheduler).Scan(&acquired1)
|
|
Expect(acquired1).To(BeTrue())
|
|
|
|
// Instance 2 cannot acquire
|
|
var acquired2 bool
|
|
conn2.QueryRowContext(context.Background(),
|
|
"SELECT pg_try_advisory_lock($1)", advisorylock.KeyCronScheduler).Scan(&acquired2)
|
|
Expect(acquired2).To(BeFalse())
|
|
|
|
// Instance 1 releases
|
|
conn1.ExecContext(context.Background(),
|
|
"SELECT pg_advisory_unlock($1)", advisorylock.KeyCronScheduler)
|
|
|
|
// Now instance 2 can acquire
|
|
conn2.QueryRowContext(context.Background(),
|
|
"SELECT pg_try_advisory_lock($1)", advisorylock.KeyCronScheduler).Scan(&acquired2)
|
|
Expect(acquired2).To(BeTrue())
|
|
conn2.ExecContext(context.Background(),
|
|
"SELECT pg_advisory_unlock($1)", advisorylock.KeyCronScheduler)
|
|
})
|
|
})
|
|
|
|
Context("Without --distributed", func() {
|
|
It("should use local channel without --distributed", func() {
|
|
appCfg := config.NewApplicationConfig()
|
|
Expect(appCfg.Distributed.Enabled).To(BeFalse())
|
|
|
|
// Without distributed mode, jobs use local in-process dispatch.
|
|
// The JobStore can still be used standalone with SQLite or in-memory.
|
|
//
|
|
// The bus-URL half of this assertion went with the field it read;
|
|
// core/config's "broker surface" spec pins its absence.
|
|
})
|
|
})
|
|
})
|
|
|
|
// fixedAgent is a selection that always names one connected agent worker. The
|
|
// selection itself is pinned against real connection rows in
|
|
// core/services/nodes; what this suite drives is the dispatch that follows it.
|
|
type fixedAgent struct{}
|
|
|
|
func (fixedAgent) PickConnected(context.Context) (string, string, error) {
|
|
return "agent-node-1", nodes.NodeTypeAgent, nil
|
|
}
|
|
|
|
// scriptedWorker answers a streaming control RPC with a scripted reply, so this
|
|
// suite exercises the claim, the persist and the settle against a real database
|
|
// without needing a worker process.
|
|
type scriptedWorker struct {
|
|
calls atomic.Int32
|
|
reply jobs.ClaimReply
|
|
err error
|
|
}
|
|
|
|
func (w *scriptedWorker) CallStreaming(_ context.Context, _, _ string, _, reply any,
|
|
_ func(string, json.RawMessage)) error {
|
|
w.calls.Add(1)
|
|
if w.err != nil {
|
|
return w.err
|
|
}
|
|
if out, ok := reply.(*jobs.ClaimReply); ok {
|
|
*out = w.reply
|
|
}
|
|
return nil
|
|
}
|