299 lines
10 KiB
Go
299 lines
10 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package busctl
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
|
|
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
|
)
|
|
|
|
var (
|
|
statusReadDir = os.ReadDir
|
|
statusStat = os.Stat
|
|
statusDial = func(endpoint string) (net.Conn, error) { return transport.Dial(endpoint) }
|
|
queryStatus = QueryStatus
|
|
)
|
|
|
|
// BusEntryState classifies a discovered bus directory's runtime state.
|
|
// Used by `dws event status/list` to render the table and by
|
|
// --fail-on-orphan to drive exit code.
|
|
type BusEntryState string
|
|
|
|
const (
|
|
// BusStateRunning: bus.lock holds an alive PID — the daemon is up.
|
|
BusStateRunning BusEntryState = "running"
|
|
// BusStateOrphan: bus.meta exists but bus.lock PID is dead. The user
|
|
// should `dws event stop --client-id <id>` (which detects the dead
|
|
// PID and unblocks fresh starts) or rm -rf the working directory.
|
|
BusStateOrphan BusEntryState = "orphan"
|
|
// BusStateNotRunning: directory exists (e.g. bus.meta retained for
|
|
// historic reasons) but bus.lock is missing or empty. Clean state.
|
|
BusStateNotRunning BusEntryState = "not_running"
|
|
)
|
|
|
|
// BusEntry is one bus working directory found on disk plus its detected
|
|
// lifecycle state. EnumerateBuses produces these; the cobra layer joins
|
|
// them with QueryStatus output to render the full status view.
|
|
type BusEntry struct {
|
|
WorkDir string `json:"workdir"`
|
|
Edition string `json:"edition"`
|
|
SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"`
|
|
ClientIDHash string `json:"client_id_hash"`
|
|
IdentityHash string `json:"identity_hash,omitempty"`
|
|
HolderPID int `json:"holder_pid"`
|
|
State BusEntryState `json:"state"`
|
|
// Meta, if non-nil, lets list/status display the original ClientID
|
|
// (reverse-mapped from the hash) and the bus start time.
|
|
Meta *bus.Meta `json:"meta,omitempty"`
|
|
}
|
|
|
|
// IPCEndpoint returns the IPC endpoint for this entry. Delegates to
|
|
// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the
|
|
// bus daemon bound (a private per-user runtime path on Unix and a named pipe
|
|
// on Windows).
|
|
func (e BusEntry) IPCEndpoint() string {
|
|
hash := e.ClientIDHash
|
|
if e.IdentityHash != "" {
|
|
hash = e.IdentityHash
|
|
}
|
|
return dwsevent.IPCEndpoint(e.WorkDir, e.Edition, e.SourceKind, hash)
|
|
}
|
|
|
|
// EnumerateBuses scans <configDir>/events/<editionFilter>/*/ for bus
|
|
// working directories. An empty editionFilter scans every edition
|
|
// directory found under events/.
|
|
//
|
|
// Returns a deterministic slice sorted by (edition, source_kind, identity_hash).
|
|
// Missing/inaccessible directories are skipped silently — list/status
|
|
// commands should still succeed when only some editions have ever run a
|
|
// bus.
|
|
func EnumerateBuses(configDir string, editionFilter string) ([]BusEntry, error) {
|
|
root := filepath.Join(configDir, "events")
|
|
editions, err := listSubdirs(root)
|
|
if err != nil {
|
|
// events/ might not exist if no bus ever ran — that's fine,
|
|
// surface an empty list rather than an error.
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("busctl: scan events dir: %w", err)
|
|
}
|
|
|
|
var out []BusEntry
|
|
for _, ed := range editions {
|
|
if editionFilter != "" && ed != editionFilter {
|
|
continue
|
|
}
|
|
editionDir := filepath.Join(root, ed)
|
|
hashDirs, err := listSubdirs(editionDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, h := range hashDirs {
|
|
candidate := filepath.Join(editionDir, h)
|
|
if isSourceKindDir(h) {
|
|
identityDirs, err := listSubdirs(candidate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, ih := range identityDirs {
|
|
workDir := filepath.Join(candidate, ih)
|
|
out = append(out, inspectEntry(workDir, ed, h, ih))
|
|
}
|
|
continue
|
|
}
|
|
// Legacy v1 app-stream layout: events/<edition>/<client_hash>.
|
|
out = append(out, inspectEntry(candidate, ed, "", h))
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Edition != out[j].Edition {
|
|
return out[i].Edition < out[j].Edition
|
|
}
|
|
if out[i].SourceKind != out[j].SourceKind {
|
|
return out[i].SourceKind < out[j].SourceKind
|
|
}
|
|
return out[i].IdentityHash < out[j].IdentityHash
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// FindBusByClientID is the "current ClientID" lookup used by `event status`
|
|
// (no --all). Returns the entry for the given (edition, clientIDHash) pair
|
|
// or nil if no bus has ever started for it. The caller derives the hash
|
|
// using event.ClientIDHash.
|
|
func FindBusByClientID(configDir, editionName, clientIDHash string) *BusEntry {
|
|
workDir := filepath.Join(configDir, "events", editionName, string(dwsevent.SourceKindAppStream), clientIDHash)
|
|
if _, err := statusStat(workDir); err != nil {
|
|
legacy := filepath.Join(configDir, "events", editionName, clientIDHash)
|
|
if _, legacyErr := statusStat(legacy); legacyErr != nil {
|
|
return nil
|
|
}
|
|
workDir = legacy
|
|
}
|
|
e := inspectEntry(workDir, editionName, string(dwsevent.SourceKindAppStream), clientIDHash)
|
|
return &e
|
|
}
|
|
|
|
// FindBusByIdentity looks up a bus in the source-kind-aware layout.
|
|
func FindBusByIdentity(configDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) *BusEntry {
|
|
if sourceKind == "" {
|
|
sourceKind = dwsevent.SourceKindAppStream
|
|
}
|
|
workDir := filepath.Join(configDir, "events", editionName, string(sourceKind), identityHash)
|
|
if _, err := statusStat(workDir); err != nil {
|
|
return nil
|
|
}
|
|
e := inspectEntry(workDir, editionName, string(sourceKind), identityHash)
|
|
return &e
|
|
}
|
|
|
|
// listSubdirs returns immediate subdirectories of path, by basename. Ignores
|
|
// regular files. Returns the err from ReadDir unchanged (callers handle
|
|
// os.IsNotExist).
|
|
func listSubdirs(path string) ([]string, error) {
|
|
ents, err := statusReadDir(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(ents))
|
|
for _, e := range ents {
|
|
if e.IsDir() {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// inspectEntry reads bus.meta + bus.lock and derives the lifecycle state.
|
|
// Never returns an error: any read failure folds into BusStateNotRunning.
|
|
func inspectEntry(workDir, editionName, sourceKindRaw, identityHash string) BusEntry {
|
|
sourceKind := dwsevent.SourceKind(sourceKindRaw)
|
|
if sourceKind == "" {
|
|
sourceKind = dwsevent.SourceKindAppStream
|
|
}
|
|
e := BusEntry{
|
|
WorkDir: workDir,
|
|
Edition: editionName,
|
|
SourceKind: sourceKind,
|
|
ClientIDHash: identityHash,
|
|
IdentityHash: identityHash,
|
|
State: BusStateNotRunning,
|
|
}
|
|
if m, err := bus.ReadMeta(workDir); err == nil {
|
|
e.Meta = m
|
|
if m.SourceKind != "" {
|
|
e.SourceKind = m.SourceKind
|
|
}
|
|
if m.IdentityHash != "" {
|
|
e.IdentityHash = m.IdentityHash
|
|
e.ClientIDHash = m.IdentityHash
|
|
}
|
|
}
|
|
pid := bus.ReadHolderPID(filepath.Join(workDir, bus.LockFileName))
|
|
e.HolderPID = pid
|
|
switch {
|
|
case pid > 0 && process.Alive(pid):
|
|
e.State = BusStateRunning
|
|
case pid > 0 && !process.Alive(pid):
|
|
e.State = BusStateOrphan
|
|
case pid == 0 && e.Meta != nil:
|
|
// meta retained but lock cleared — bus exited cleanly. Render as
|
|
// not_running (with the historical meta visible if the user asked
|
|
// for --format json).
|
|
e.State = BusStateNotRunning
|
|
}
|
|
return e
|
|
}
|
|
|
|
func isSourceKindDir(name string) bool {
|
|
return name == string(dwsevent.SourceKindAppStream) || name == string(dwsevent.SourceKindPersonalStream)
|
|
}
|
|
|
|
// DefaultStatusRPCTimeout caps how long QueryStatus waits for the bus to
|
|
// reply. 2s is generous — the bus's status_resp is a synchronous
|
|
// in-memory snapshot, sub-millisecond in practice; the timeout exists
|
|
// only to bound pathological cases (bus stuck in shutdown).
|
|
const DefaultStatusRPCTimeout = 2 * time.Second
|
|
|
|
// QueryStatus dials the bus IPC, sends Hello with Role=status, sends a
|
|
// StatusReq, reads exactly one StatusResp, and closes. Returns the
|
|
// decoded response or an error if any step fails.
|
|
//
|
|
// Used by `dws event status` and `dws event list` to fetch live
|
|
// per-consumer / per-event-type counters. The bus's handleStatusRPC
|
|
// path (see internal/event/bus/daemon.go) handles this connection
|
|
// without registering with the Hub — ad-hoc tooling does not count as
|
|
// a consumer in `status.active_consumers`.
|
|
func QueryStatus(endpoint string) (*transport.StatusResp, error) {
|
|
conn, err := statusDial(endpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("busctl: dial bus for status: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
|
|
|
|
w := transport.NewWriter(conn)
|
|
r := transport.NewReader(conn)
|
|
|
|
if err := w.WriteJSON(transport.Hello{
|
|
Type: transport.FrameTypeHello,
|
|
ConsumerPID: os.Getpid(),
|
|
Role: transport.HelloRoleStatus,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("busctl: write hello: %w", err)
|
|
}
|
|
if err := w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq}); err != nil {
|
|
return nil, fmt.Errorf("busctl: write status_req: %w", err)
|
|
}
|
|
var resp transport.StatusResp
|
|
if err := r.ReadJSON(&resp); err != nil {
|
|
return nil, fmt.Errorf("busctl: read status_resp: %w", err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// EntryStatus combines static FS info (BusEntry) with the live RPC
|
|
// snapshot (StatusResp). For not_running / orphan entries Live is nil.
|
|
type EntryStatus struct {
|
|
Entry BusEntry `json:"entry"`
|
|
Live *transport.StatusResp `json:"live,omitempty"`
|
|
}
|
|
|
|
// QueryEntry fetches the live status for one BusEntry. Returns the entry
|
|
// wrapped with a nil Live when state != running (or when the dial fails).
|
|
// Errors from QueryStatus are folded into Live=nil so the caller's table
|
|
// rendering does not need to surface per-bus dial failures (they are
|
|
// already conveyed by State).
|
|
func QueryEntry(entry BusEntry) EntryStatus {
|
|
out := EntryStatus{Entry: entry}
|
|
if entry.State != BusStateRunning {
|
|
return out
|
|
}
|
|
live, err := queryStatus(entry.IPCEndpoint())
|
|
if err == nil {
|
|
out.Live = live
|
|
}
|
|
return out
|
|
}
|