This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / mill / executor / observe.go
5.9 kB 251 lines
1package executor 2 3import ( 4 "context" 5 "crypto/sha256" 6 "encoding/hex" 7 "fmt" 8 "io" 9 "os" 10 "strings" 11 "sync" 12 "time" 13 14 "github.com/hpcloud/tail" 15 "tangled.org/core/api/tangled" 16 millproto "tangled.org/core/spindle/mill/proto" 17 millv1 "tangled.org/core/spindle/mill/proto/gen" 18 "tangled.org/core/spindle/models" 19 "tangled.org/core/spindle/secrets" 20) 21 22func (e *Executor) observeLoop(ctx context.Context, sub <-chan struct{}, cursor int64) { 23 ticker := time.NewTicker(5 * time.Second) 24 defer ticker.Stop() 25 26 for { 27 select { 28 case <-ctx.Done(): 29 return 30 case <-sub: 31 case <-ticker.C: 32 } 33 e.drainEvents(&cursor) 34 } 35} 36 37func (e *Executor) drainEvents(cursor *int64) { 38 events, err := e.db.GetEvents(*cursor, 128) 39 if err != nil { 40 e.l.Error("drain status events failed", "err", err) 41 return 42 } 43 for _, ev := range events { 44 if ev.Created > *cursor { 45 *cursor = ev.Created 46 } 47 st, ok := parseStatus(ev.EventJson) 48 if !ok { 49 continue 50 } 51 if err := e.onStatusRow(st); err != nil { 52 e.l.Error("process status row failed", "err", err) 53 } 54 } 55} 56 57func (e *Executor) onStatusRow(st *tangled.PipelineStatus) error { 58 res := e.reservationFor(st.Pipeline, st.Workflow) 59 if res == nil { 60 return nil 61 } 62 63 if models.StatusKind(st.Status).IsFinish() { 64 return e.finishJob(res, st) 65 } 66 67 return e.appendStatus(res.leaseID, st) 68} 69 70func (e *Executor) finishJob(res *reservation, st *tangled.PipelineStatus) error { 71 e.mu.Lock() 72 if e.active[res.leaseID] != res { 73 e.mu.Unlock() 74 return nil 75 } 76 cancelled := res.cancelled 77 e.mu.Unlock() 78 79 // 1. Finalize log tail first so all log lines precede terminal event 80 if res.stopTail != nil { 81 res.stopTail() 82 } 83 84 terminalStatus := st.Status 85 if cancelled { 86 terminalStatus = string(models.StatusKindCancelled) 87 } 88 var logDir string 89 if e.cfg != nil { 90 logDir = e.cfg.Server.LogDir 91 } 92 logPath := models.LogFilePath(logDir, res.wid) 93 94 var errStr string 95 if st != nil && st.Error != nil { 96 errStr = *st.Error 97 } 98 var exitCode int64 99 if st != nil && st.ExitCode != nil { 100 exitCode = *st.ExitCode 101 } 102 103 // Calculate SHA256 of log file 104 hash := "" 105 if f, err := os.Open(logPath); err == nil { 106 h := sha256.New() 107 if _, err := io.Copy(h, f); err == nil { 108 hash = "sha256:" + hex.EncodeToString(h.Sum(nil)) 109 } 110 _ = f.Close() 111 } 112 113 // refs are opaque keys interpreted by the configured artifact store 114 ref := "logs/" + res.leaseID + ".log" 115 116 // 2. Persist restart-retryable pending artifact state 117 if e.db != nil { 118 _ = e.db.SavePendingArtifact(res.leaseID, res.wid.Name, terminalStatus, errStr, exitCode, ref, hash) 119 } 120 121 // 3. Upload artifact with non-cancelled cleanup context 122 cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 2*time.Minute) 123 defer cancel() 124 125 if e.writer != nil { 126 if f, err := os.Open(logPath); err == nil { 127 defer f.Close() 128 if uploadErr := e.writer.Put(cleanupCtx, ref, f); uploadErr != nil { 129 e.l.Error("artifact upload failed", "lease", res.leaseID, "err", uploadErr) 130 return fmt.Errorf("artifact upload: %w", uploadErr) 131 } 132 } 133 } 134 135 // 4. Append terminal event (with LogArtifact) to outbox 136 if err := e.appendTerminalWithArtifact(res.leaseID, terminalStatus, st, ref, hash); err != nil { 137 return err 138 } 139 140 // 5. Remove pending artifact state after terminal outbox append succeeds 141 if e.db != nil { 142 _ = e.db.RemovePendingArtifact(res.leaseID) 143 } 144 e.mu.Lock() 145 cleanup := e.removeReservationLocked(res, false) 146 e.mu.Unlock() 147 148 cleanup() 149 150 e.pushSnapshot() 151 return nil 152} 153 154func (e *Executor) reservationFor(pipelineAturi, workflow string) *reservation { 155 e.mu.Lock() 156 defer e.mu.Unlock() 157 for _, res := range e.active { 158 if string(res.wid.PipelineId.AtUri()) == pipelineAturi && res.wid.Name == workflow { 159 return res 160 } 161 } 162 return nil 163} 164 165func (e *Executor) maskSecrets(res *reservation, text string) string { 166 if res == nil || res.vault == nil { 167 return text 168 } 169 for _, s := range res.vault.secrets { 170 if s.Value != "" { 171 text = strings.ReplaceAll(text, s.Value, "***") 172 } 173 } 174 return text 175} 176 177func (e *Executor) SendLiveLog(leaseID string, raw []byte) error { 178 e.connMu.Lock() 179 enc := e.enc 180 e.connMu.Unlock() 181 if enc == nil { 182 return nil 183 } 184 return enc.Encode(&millproto.Message{ 185 LiveLog: &millv1.LiveLog{ 186 LeaseId: leaseID, 187 RawJson: raw, 188 }, 189 }) 190} 191 192func (e *Executor) startTail(res *reservation) { 193 path := models.LogFilePath(e.cfg.Server.LogDir, res.wid) 194 t, err := tail.TailFile(path, tail.Config{ 195 Follow: true, 196 ReOpen: true, 197 MustExist: false, 198 Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekStart}, 199 Logger: tail.DiscardingLogger, 200 }) 201 if err != nil { 202 e.l.Error("tail log file failed", "wid", res.wid, "err", err) 203 return 204 } 205 206 done := make(chan struct{}) 207 go func() { 208 defer close(done) 209 for line := range t.Lines { 210 if line == nil || line.Err != nil { 211 continue 212 } 213 masked := e.maskSecrets(res, line.Text) 214 _ = e.SendLiveLog(res.leaseID, []byte(masked+"\n")) 215 } 216 }() 217 218 var once sync.Once 219 res.stopTail = func() { 220 once.Do(func() { 221 _ = t.StopAtEOF() 222 <-done 223 }) 224 } 225} 226 227type memVault struct { 228 secrets []secrets.UnlockedSecret 229} 230 231func newMemVault(pb []*millv1.Secret) *memVault { 232 v := &memVault{secrets: make([]secrets.UnlockedSecret, 0, len(pb))} 233 for _, s := range pb { 234 v.secrets = append(v.secrets, secrets.UnlockedSecret{ 235 Key: s.Key, 236 Value: s.Value, 237 }) 238 } 239 return v 240} 241 242func (v *memVault) GetSecretsUnlocked(ctx context.Context, repo secrets.RepoIdentifier) ([]secrets.UnlockedSecret, error) { 243 return v.secrets, nil 244} 245func (v *memVault) GetSecretsLocked(ctx context.Context, repo secrets.RepoIdentifier) ([]secrets.LockedSecret, error) { 246 return nil, nil 247} 248func (v *memVault) AddSecret(ctx context.Context, s secrets.UnlockedSecret) error { return nil } 249func (v *memVault) RemoveSecret(ctx context.Context, s secrets.Secret[any]) error { return nil } 250 251var _ secrets.Manager = (*memVault)(nil)