Report queued tasks the scheduling pass never considers
A task in retry backoff was filtered out before the candidate loop, so it recorded no rejection at all: queued, apparently assignable, and silent. That is the exact shape that made F5 take a live session to diagnose. It now reports "retry backoff until <time>". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +0,0 @@
|
||||
// orchestra-password prints a bcrypt hash suitable for ORCHESTRA_WEB_PASSWORD_HASH.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprint(os.Stderr, "Password: ")
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil || len(password) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "password is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hash password:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(hash))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// orchestra-user manages browser-operator accounts in Orchestra's embedded
|
||||
// credential database. Passwords are read from the terminal and hashed inside
|
||||
// the database; no reusable hash has to be copied into deployment config.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"orchestra/internal/authn"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " orchestra-user set -data DIR -username NAME")
|
||||
fmt.Fprintln(os.Stderr, " orchestra-user list -data DIR")
|
||||
}
|
||||
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
value, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return string(value), err
|
||||
}
|
||||
|
||||
func open(data string) *authn.Store {
|
||||
if data == "" {
|
||||
log.Fatal("-data is required")
|
||||
}
|
||||
users, err := authn.Open(authn.Path(data))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "set":
|
||||
flags := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
data := flags.String("data", "", "Orchestra data directory")
|
||||
username := flags.String("username", "", "operator username")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
password, err := readPassword("New password: ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
confirmation, err := readPassword("Confirm password: ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if password != confirmation {
|
||||
log.Fatal("passwords do not match")
|
||||
}
|
||||
users := open(*data)
|
||||
defer users.Close()
|
||||
user, created, err := users.SetPassword(*username, password)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
fmt.Printf("created operator %s\n", user.Username)
|
||||
} else {
|
||||
fmt.Printf("updated password for %s\n", user.Username)
|
||||
}
|
||||
case "list":
|
||||
flags := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
data := flags.String("data", "", "Orchestra data directory")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
users := open(*data)
|
||||
defer users.Close()
|
||||
list, err := users.Users()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, user := range list {
|
||||
fmt.Println(user.Username)
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
+38
-46
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"orchestra/internal/admin"
|
||||
"orchestra/internal/authn"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/buildinfo"
|
||||
"orchestra/internal/delivery"
|
||||
@@ -150,6 +151,37 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
users, err := authn.Open(authn.Path(dir))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer users.Close()
|
||||
userCount, err := users.Count()
|
||||
if err != nil {
|
||||
log.Fatalf("read operator database: %v", err)
|
||||
}
|
||||
legacyUsername := os.Getenv("ORCHESTRA_WEB_USERNAME")
|
||||
legacyPasswordHash := os.Getenv("ORCHESTRA_WEB_PASSWORD_HASH")
|
||||
if userCount == 0 {
|
||||
if legacyUsername != "" || legacyPasswordHash != "" {
|
||||
if legacyUsername == "" || legacyPasswordHash == "" {
|
||||
log.Fatal("operator database is empty and the legacy web credential is incomplete: both ORCHESTRA_WEB_USERNAME and ORCHESTRA_WEB_PASSWORD_HASH are required for one-time migration")
|
||||
}
|
||||
imported, importErr := users.ImportBcrypt(legacyUsername, legacyPasswordHash)
|
||||
if importErr != nil {
|
||||
log.Fatalf("migrate legacy web credential: %v", importErr)
|
||||
}
|
||||
if imported {
|
||||
userCount = 1
|
||||
log.Printf("migrated web operator %q into %s; remove ORCHESTRA_WEB_USERNAME and ORCHESTRA_WEB_PASSWORD_HASH from the deployment environment", legacyUsername, authn.Path(dir))
|
||||
}
|
||||
}
|
||||
if userCount == 0 {
|
||||
log.Fatalf("operator database is empty: stop Orchestra and run orchestra-user set -data %s -username NAME", dir)
|
||||
}
|
||||
} else if legacyUsername != "" || legacyPasswordHash != "" {
|
||||
log.Printf("operator database already contains %d account(s); legacy ORCHESTRA_WEB_USERNAME and ORCHESTRA_WEB_PASSWORD_HASH are ignored and should be removed", userCount)
|
||||
}
|
||||
var rr registry.Registry
|
||||
var rt *router.Router
|
||||
var coordinator *orchestrator.Coordinator
|
||||
@@ -275,53 +307,13 @@ func main() {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// B18: the UI is a full control plane — it can create tasks, release or
|
||||
// complete them, and inject approval keystrokes into live panes. It has
|
||||
// one explicit operator identity and is never enabled by a missing env var.
|
||||
webCredentials := authz.WebCredentials{Username: os.Getenv("ORCHESTRA_WEB_USERNAME"), PasswordHash: os.Getenv("ORCHESTRA_WEB_PASSWORD_HASH")}
|
||||
if err := webCredentials.Validate(); err != nil {
|
||||
log.Fatalf("web login configuration: %v", err)
|
||||
}
|
||||
// complete them, and inject approval keystrokes into live panes. Browser
|
||||
// sessions are backed by operator accounts in the embedded auth database;
|
||||
// no missing environment variable can open this surface.
|
||||
sessions := &authz.Sessions{}
|
||||
// A browser login exchanges verified credentials for an HttpOnly cookie.
|
||||
mux.HandleFunc("/v1/ui/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
// Expire the browser credential even if it is already absent or stale.
|
||||
// The client never has access to the HttpOnly value, so this is the
|
||||
// only reliable way for an operator to end a browser session.
|
||||
if cookie, err := r.Cookie(authz.SessionCookie); err == nil {
|
||||
sessions.Revoke(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: authz.SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == ""})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil || !webCredentials.Authenticate(body.Username, body.Password) {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
v, err := sessions.Issue()
|
||||
if err != nil {
|
||||
http.Error(w, "session unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authz.SessionCookie, Value: v, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteStrictMode,
|
||||
Secure: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == "",
|
||||
MaxAge: int((12 * time.Hour).Seconds()),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
browserAuth := authn.HTTP{Users: users, Sessions: sessions, SecureCookie: os.Getenv("ORCHESTRA_UI_INSECURE_COOKIE") == ""}
|
||||
mux.HandleFunc(authz.SessionPath, browserAuth.Session)
|
||||
mux.HandleFunc("/v1/ui/account", browserAuth.Account)
|
||||
// Browser-specific endpoints intentionally present a joined read model;
|
||||
// raw lifecycle endpoints below remain stable for workers and harnesses.
|
||||
mux.Handle("/v1/ui/", ui.Server{Store: s, Workers: workers, Coordinator: coordinator, Route: func(e domain.Event) error {
|
||||
|
||||
Reference in New Issue
Block a user