okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
d9c1a13
main
1package localdaemon 2 3import ( 4"albedo/cli/internal/daemon" 5"context" 6"encoding/json" 7"errors" 8"fmt" 9"io" 10"net/http" 11"os" 12"os/exec" 13"path/filepath" 14"strings" 15"time" 16) 17 18const ( 19pollInterval = 10 * time .Millisecond 20startupTimeout = 30 * time .Second 21// A ready daemon's owner observations may queue behind other work. 22discoveryProbeTimeout = 5 * time .Second 23) 24 25type DiscoveryKind int 26 27const ( 28Absent DiscoveryKind = iota 29Stale 30Running 31) 32 33type Discovery struct { 34Kind DiscoveryKind 35Snapshot daemon.ConnectionSnapshot 36Server daemon.ServerInfo 37} 38 39type LocalOptions struct { 40HomeDir string 41ProjectRoot string 42} 43 44type LocalErrorKind string 45 46const ( 47InvalidDiscovery LocalErrorKind = "invalid_discovery" 48DiscoveryUnavailable LocalErrorKind = "discovery_unavailable" 49UnreachableDaemon LocalErrorKind = "unreachable_daemon" 50UnhealthyDaemon LocalErrorKind = "unhealthy_daemon" 51IncompatibleDaemon LocalErrorKind = "incompatible_daemon" 52AuthenticationFailed LocalErrorKind = "authentication_failed" 53TargetChanged LocalErrorKind = "target_changed" 54LauncherLockFailure LocalErrorKind = "launcher_lock_failure" 55StartupFailed LocalErrorKind = "startup_failed" 56HomeInUse LocalErrorKind = "home_in_use" 57) 58 59type LocalError struct { 60Kind LocalErrorKind 61HomeDir string 62Cause error 63} 64 65func (e * LocalError )Error ()string { 66return fmt .Sprintf ("Albedo %s in %s: %v" ,strings .ReplaceAll (string (e .Kind ),"_" ," " ),e .HomeDir ,e .Cause ) 67} 68func (e * LocalError )Unwrap ()error {return e .Cause } 69 70// Discover distinguishes missing and proven stale records from unsafe failures. 71// A valid live endpoint is returned even when its API contract needs upgrading. 72func Discover (ctx context.Context ,homeDir string ) (Discovery ,error ) { 73if err := ctx .Err ();err != nil { 74return Discovery {},err 75 } 76fail := func (kind LocalErrorKind ,cause error ) (Discovery ,error ) { 77return Discovery {},& LocalError {Kind :kind ,HomeDir :homeDir ,Cause :cause } 78 } 79file ,err := os .Open (filepath .Join (homeDir ,"daemon.json" )) 80if errors .Is (err ,os .ErrNotExist ) { 81return Discovery {Kind :Absent },nil 82 } 83if err != nil { 84return fail (DiscoveryUnavailable ,err ) 85 } 86defer file .Close () 87data ,err := io .ReadAll (io .LimitReader (file ,64 * 1024 + 1 )) 88if err != nil { 89return fail (DiscoveryUnavailable ,err ) 90 } 91if len (data )> 64 * 1024 { 92return fail (InvalidDiscovery ,errors .New ("daemon.json exceeds 64 KiB" )) 93 } 94var snapshot daemon.ConnectionSnapshot 95if err := json .Unmarshal (data ,& snapshot );err != nil { 96return fail (InvalidDiscovery ,err ) 97 } 98if snapshot .Pid <= 0 || snapshot .Port < 1 || snapshot .Port > 65535 || snapshot .Token == "" || snapshot .Version <= 0 { 99return fail (InvalidDiscovery ,errors .New ("daemon.json requires a positive PID, valid port, token and protocol version" )) 100 } 101probeCtx ,cancel := context .WithTimeout (ctx ,discoveryProbeTimeout ) 102connection := daemon .NewConnection (snapshot ,nil ) 103server ,err := daemon .ProbeServer (probeCtx ,connection ) 104connection .HTTPClient ().CloseIdleConnections () 105cancel () 106if err != nil { 107if ctx .Err ()!= nil { 108return Discovery {},ctx .Err () 109 } 110if ! processAlive (snapshot .Pid )&& endpointRefused (err ) { 111return Discovery {Kind :Stale ,Snapshot :snapshot },nil 112 } 113if api ,ok := errors.AsType [* daemon.APIError ](err );ok { 114if api .StatusCode == http .StatusUnauthorized || api .StatusCode == http .StatusForbidden { 115return fail (AuthenticationFailed ,err ) 116 } 117if api .StatusCode == http .StatusNotFound && snapshot .Version != daemon .ProtocolVersion { 118return fail (IncompatibleDaemon ,& daemon.CompatibilityError {Version :snapshot .Version }) 119 } 120return fail (UnhealthyDaemon ,err ) 121 } 122if _ ,ok := errors.AsType [* daemon.ProtocolError ](err );ok { 123return fail (UnhealthyDaemon ,err ) 124 } 125return fail (UnreachableDaemon ,err ) 126 } 127if server .Protocol != snapshot .Version || (server .Build != "" && server .Build != snapshot .Build )|| 128 (server .Digest != "" && snapshot .Digest != "" && server .Digest != snapshot .Digest ) { 129return fail (InvalidDiscovery ,errors .New ("server identity does not match daemon.json" )) 130 } 131if snapshot .InstanceID != "" && snapshot .InstanceID != server .InstanceID { 132return fail (InvalidDiscovery ,errors .New ("server identity does not match daemon.json" )) 133 } 134snapshot .InstanceID ,snapshot .Digest = server .InstanceID ,server .Digest 135discovery := Discovery {Kind :Running ,Snapshot :snapshot ,Server :server } 136return discovery ,daemon .CheckCompatible (server ) 137} 138 139// Rediscover waits for a verified replacement endpoint without starting one. 140func Rediscover (ctx context.Context ,homeDir string ) (daemon.ConnectionSnapshot ,error ) { 141for range 20 { 142discovery ,err := Discover (ctx ,homeDir ) 143if err == nil && discovery .Kind == Running { 144return discovery .Snapshot ,nil 145 } 146if err != nil { 147var local * LocalError 148if ! errors .As (err ,& local )|| (local .Kind != UnreachableDaemon && local .Kind != UnhealthyDaemon ) { 149return daemon.ConnectionSnapshot {},err 150 } 151var api * daemon.APIError 152if errors .As (err ,& api )&& ! api .DaemonRestarting () { 153return daemon.ConnectionSnapshot {},err 154 } 155if _ ,ok := errors.AsType [* daemon.ProtocolError ](err );ok { 156return daemon.ConnectionSnapshot {},err 157 } 158 } 159timer := time .NewTimer (100 * time .Millisecond ) 160select { 161case <- ctx .Done (): 162timer .Stop () 163return daemon.ConnectionSnapshot {},ctx .Err () 164case <- timer .C : 165 } 166 } 167return daemon.ConnectionSnapshot {},& LocalError {Kind :UnreachableDaemon ,HomeDir :homeDir ,Cause :errors .New ("could not rediscover a ready daemon; check whether it is running" )} 168} 169 170func resolveDaemonExecutable (path string ) (string ,error ) { 171if ! filepath .IsAbs (path ) { 172return "" ,fmt .Errorf ("ALBEDO_DAEMON must point to an executable using its full path: %q" ,path ) 173 } 174resolved ,err := exec .LookPath (path ) 175if err != nil { 176return "" ,fmt .Errorf ("cannot run ALBEDO_DAEMON (%q): %w" ,path ,err ) 177 } 178info ,err := os .Stat (resolved ) 179if err != nil { 180return "" ,err 181 } 182if info .IsDir () { 183return "" ,fmt .Errorf ("ALBEDO_DAEMON points to a directory (%q)" ,path ) 184 } 185return resolved ,nil 186} 187 188// Local launch filters provider overrides; daemon bootstrap owns all defaults. 189func daemonCommand (options LocalOptions ) (* exec.Cmd ,error ) { 190return LocalCommand (context .Background (),options ) 191} 192 193// LocalCommand selects the local daemon executable without launching its server. 194func LocalCommand (ctx context.Context ,options LocalOptions ,args ... string ) (* exec.Cmd ,error ) { 195executable := os .Getenv ("ALBEDO_DAEMON" ) 196if executable == "" { 197if options .ProjectRoot == "" { 198return nil ,errors .New ("set ALBEDO_DAEMON to its full executable path or ALBEDO_ROOT to a source checkout" ) 199 } 200root ,err := filepath .Abs (options .ProjectRoot ) 201if err != nil { 202return nil ,err 203 } 204if _ ,err := exec .LookPath ("gleam" );err != nil { 205return nil ,fmt .Errorf ("source daemon requires gleam: %w" ,err ) 206 } 207executable = filepath .Join (root ,"priv" ,"bin" ,"albedo-daemon" ) 208 } 209executable ,err := resolveDaemonExecutable (executable ) 210if err != nil { 211return nil ,err 212 } 213cmd := exec .CommandContext (ctx ,executable ,args ... ) 214for _ ,entry := range os .Environ () { 215key ,_ ,_ := strings .Cut (entry ,"=" ) 216switch key { 217case "ALBEDO_API_KEY" ,"ALBEDO_MODEL" ,"ALBEDO_BASE_URL" ,"ALBEDO_PROTOCOL" ,"ALBEDO_HOME" : 218default : 219cmd .Env = append (cmd .Env ,entry ) 220 } 221 } 222cmd .Env = append (cmd .Env ,"ALBEDO_HOME=" + options .HomeDir ) 223return cmd ,nil 224} 225 226func waitForPoll (ctx context.Context )error { 227timer := time .NewTimer (pollInterval ) 228defer timer .Stop () 229select { 230case <- ctx .Done (): 231return ctx .Err () 232case <- timer .C : 233return nil 234 } 235} 236 237func acquireLauncher (ctx context.Context ,homeDir string ) (* os.File ,error ) { 238// The launcher must create its lock directory before the daemon can start. 239if err := os .MkdirAll (homeDir ,0700 );err != nil { 240return nil ,err 241 } 242file ,err := os .OpenFile (filepath .Join (homeDir ,"launcher.lock" ),os .O_CREATE | os .O_RDWR ,0600 ) 243if err != nil { 244return nil ,err 245 } 246for { 247if err := ctx .Err ();err != nil { 248file .Close () 249return nil ,err 250 } 251acquired ,err := tryLauncherLock (file ) 252if err != nil { 253file .Close () 254return nil ,err 255 } 256if acquired { 257return file ,nil 258 } 259if err := waitForPoll (ctx );err != nil { 260file .Close () 261return nil ,err 262 } 263 } 264} 265 266// Launch serializes local startup without weakening the daemon's home lock. 267func Launch (parent context.Context ,options LocalOptions ) (* daemon.Connection ,error ) { 268return launchLocal (parent ,options ,nil ) 269} 270 271// Upgrade stops only the exact authenticated protocol 3 daemon approved by the caller. 272func Upgrade (parent context.Context ,options LocalOptions ,approved daemon.ConnectionSnapshot ) (* daemon.Connection ,error ) { 273return launchLocal (parent ,options ,& approved ) 274} 275 276func launchLocal (parent context.Context ,options LocalOptions ,approved * daemon.ConnectionSnapshot ) (* daemon.Connection ,error ) { 277ctx ,cancel := context .WithTimeout (parent ,startupTimeout ) 278defer cancel () 279if err := ctx .Err ();err != nil { 280return nil ,err 281 } 282if options .HomeDir == "" { 283return nil ,errors .New ("local launch requires HomeDir" ) 284 } 285home ,err := filepath .Abs (options .HomeDir ) 286if err != nil { 287return nil ,err 288 } 289options .HomeDir = home 290// Validate the replacement executable before taking any destructive action. 291cmd ,err := daemonCommand (options ) 292if err != nil { 293return nil ,err 294 } 295lock ,err := acquireLauncher (ctx ,home ) 296if err != nil { 297return nil ,& LocalError {Kind :LauncherLockFailure ,HomeDir :home ,Cause :err } 298 } 299defer lock .Close ()// Closing releases the advisory lock; its inode persists. 300discovery ,discoveryErr := Discover (ctx ,home ) 301if approved == nil { 302if discoveryErr != nil { 303return nil ,discoveryErr 304 } 305if discovery .Kind == Running { 306return daemon .Attach (ctx ,discovery .Snapshot ,func (ctx context.Context ) (daemon.ConnectionSnapshot ,error ) {return Rediscover (ctx ,home ) }) 307 } 308 }else { 309var compatible * daemon.CompatibilityError 310if discoveryErr != nil && ! errors .As (discoveryErr ,& compatible ) { 311return nil ,discoveryErr 312 } 313if discovery .Kind != Running || discovery .Snapshot != * approved { 314return nil ,& LocalError {Kind :TargetChanged ,HomeDir :home ,Cause :errors .New ("the daemon changed after approval; review the current daemon before restarting" )} 315 } 316if discovery .Server .Protocol != daemon .ProtocolVersion { 317return nil ,& daemon.CompatibilityError {Version :discovery .Server .Protocol } 318 } 319connection := daemon .NewConnection (discovery .Snapshot ,nil ) 320stopErr := daemon .StopDaemon (ctx ,connection ) 321connection .HTTPClient ().CloseIdleConnections () 322if stopErr != nil { 323return nil ,stopErr 324 } 325for processAlive (discovery .Snapshot .Pid ) { 326if err := waitForPoll (ctx );err != nil { 327return nil ,err 328 } 329 } 330current ,err := Discover (ctx ,home ) 331if err != nil { 332return nil ,err 333 } 334if current .Kind == Running { 335return nil ,& LocalError {Kind :TargetChanged ,HomeDir :home ,Cause :errors .New ("another daemon started during shutdown" )} 336 } 337 } 338return startLocal (ctx ,cmd ,options ) 339} 340 341func startLocal (ctx context.Context ,cmd * exec.Cmd ,options LocalOptions ) (* daemon.Connection ,error ) { 342logPath := filepath .Join (options .HomeDir ,"daemon.log" ) 343logFile ,err := os .OpenFile (logPath ,os .O_CREATE | os .O_WRONLY | os .O_APPEND ,0600 ) 344if err != nil { 345return nil ,err 346 } 347logStart ,_ := logFile .Seek (0 ,io .SeekEnd ) 348cmd .Stdout ,cmd .Stderr = logFile ,logFile 349detach (cmd ) 350passFileLimit () 351if err := ctx .Err ();err != nil { 352logFile .Close () 353return nil ,err 354 } 355if err := cmd .Start ();err != nil { 356logFile .Close () 357return nil ,err 358 } 359logFile .Close () 360exited := make (chan error ,1 ) 361go func () {exited <- cmd .Wait () }() 362contended := false 363for { 364discovery ,err := Discover (ctx ,options .HomeDir ) 365if err == nil && discovery .Kind == Running { 366return daemon .Attach (ctx ,discovery .Snapshot ,func (ctx context.Context ) (daemon.ConnectionSnapshot ,error ) {return Rediscover (ctx ,options .HomeDir ) }) 367 } 368if err != nil && ctx .Err ()== nil { 369var local * LocalError 370var api * daemon.APIError 371unreachable := errors .As (err ,& local )&& local .Kind == UnreachableDaemon 372starting := errors .As (err ,& api )&& api .DaemonRestarting () 373if ! unreachable && ! starting { 374return nil ,err 375 } 376 } 377select { 378case waitErr := <- exited : 379exited = nil 380var exit * exec.ExitError 381if errors .As (waitErr ,& exit )&& exit .ExitCode ()== 75 { 382contended = true 383 }else { 384return nil ,& LocalError {Kind :StartupFailed ,HomeDir :options .HomeDir ,Cause :startupExitError (waitErr ,logPath ,logStart ,options .ProjectRoot )} 385 } 386case <- ctx .Done (): 387if contended && errors .Is (ctx .Err (),context .DeadlineExceeded ) { 388return nil ,& LocalError {Kind :HomeInUse ,HomeDir :options .HomeDir ,Cause :fmt .Errorf ("home is held by another daemon or maintenance command; wait for it to finish and retry: %w" ,ctx .Err ())} 389 } 390return nil ,ctx .Err () 391case <- time .After (pollInterval ): 392 } 393 } 394} 395 396func startupExitError (waitErr error ,logPath string ,logStart int64 ,projectRoot string )error { 397message := "daemon stopped before it finished starting" 398if file ,err := os .Open (logPath );err == nil { 399defer file .Close () 400if _ ,err := file .Seek (logStart ,io .SeekStart );err == nil { 401output ,_ := io .ReadAll (io .LimitReader (file ,4096 )) 402if tail := strings .TrimSpace (string (output ));tail != "" { 403message += ":\n" + tail 404 } 405 } 406 } 407if projectRoot != "" { 408message += fmt .Sprintf ("\nsource checkout: %s; check ALBEDO_ROOT" ,projectRoot ) 409 } 410if waitErr != nil { 411return fmt .Errorf ("%s: %w" ,message ,waitErr ) 412 } 413return errors .New (message ) 414}