241 lines
6.5 KiB
Go
241 lines
6.5 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"iop/packages/go/agentconfig"
|
|
)
|
|
|
|
// RunResult is the bounded output of one discovery probe.
|
|
type RunResult struct {
|
|
Output string
|
|
}
|
|
|
|
// Runner provides the small process seam used by discovery.
|
|
type Runner interface {
|
|
LookPath(command string) (string, error)
|
|
Run(ctx context.Context, command string, args []string) (RunResult, error)
|
|
}
|
|
|
|
type osRunner struct{}
|
|
|
|
func (osRunner) LookPath(command string) (string, error) {
|
|
return exec.LookPath(command)
|
|
}
|
|
|
|
func (osRunner) Run(ctx context.Context, command string, args []string) (RunResult, error) {
|
|
output, err := exec.CommandContext(ctx, command, args...).CombinedOutput()
|
|
return RunResult{Output: string(output)}, err
|
|
}
|
|
|
|
// Discoverer validates a catalog and resolves its profiles against the host.
|
|
type Discoverer struct {
|
|
catalog agentconfig.Catalog
|
|
runner Runner
|
|
}
|
|
|
|
// NewDiscoverer constructs a host discoverer. A nil Runner uses os/exec.
|
|
func NewDiscoverer(cfg agentconfig.Catalog, runner Runner) (*Discoverer, error) {
|
|
normalized, err := agentconfig.Normalize(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if runner == nil {
|
|
runner = osRunner{}
|
|
}
|
|
return &Discoverer{catalog: normalized, runner: runner}, nil
|
|
}
|
|
|
|
// Discover returns all profile states in stable profile-ID order.
|
|
func (d *Discoverer) Discover(ctx context.Context) []Readiness {
|
|
results := make([]Readiness, 0, len(d.catalog.Profiles))
|
|
for _, profile := range d.catalog.Profiles {
|
|
results = append(results, d.discoverResolved(ctx, mustResolve(d.catalog, profile.ID)))
|
|
}
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return results[i].ProfileID < results[j].ProfileID
|
|
})
|
|
return results
|
|
}
|
|
|
|
// DiscoverProfile resolves one official profile.
|
|
func (d *Discoverer) DiscoverProfile(ctx context.Context, profileID string) (Readiness, error) {
|
|
resolved, ok := d.catalog.ResolveProfile(profileID)
|
|
if !ok {
|
|
return Readiness{}, fmt.Errorf("agent provider catalog: unknown profile %q", profileID)
|
|
}
|
|
result := d.discoverResolved(ctx, resolved)
|
|
if result.Error != nil {
|
|
return result, result.Error
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (d *Discoverer) discoverResolved(ctx context.Context, resolved agentconfig.ResolvedProfile) Readiness {
|
|
provider := resolved.Provider
|
|
model := resolved.Model
|
|
profile := resolved.Profile
|
|
base := Readiness{
|
|
ProviderID: provider.ID,
|
|
ModelID: model.ID,
|
|
ProfileID: profile.ID,
|
|
Command: provider.Command,
|
|
Capabilities: append([]string(nil), profile.Capabilities...),
|
|
}
|
|
|
|
command, err := d.runner.LookPath(provider.Command)
|
|
if err != nil {
|
|
failure := readinessFailure(
|
|
StateMissingBinary,
|
|
provider.ID, model.ID, profile.ID,
|
|
fmt.Sprintf("provider %q binary %q was not found on PATH", provider.ID, provider.Command),
|
|
ErrBinaryMissing,
|
|
)
|
|
failure.Command = provider.Command
|
|
failure.Capabilities = base.Capabilities
|
|
return failure
|
|
}
|
|
|
|
version, err := d.runProbe(ctx, command, provider.VersionProbe.Args, provider.VersionProbe.TimeoutMS)
|
|
if err != nil {
|
|
return d.probeFailure(base, "version", version, err)
|
|
}
|
|
base.Version = firstNonEmptyLine(version.Output)
|
|
|
|
auth, authErr := d.runProbe(
|
|
ctx, command, provider.Authentication.Args, provider.Authentication.TimeoutMS,
|
|
)
|
|
authText := diagnostic(auth.Output)
|
|
unauthenticated := matches(provider.Authentication.UnauthenticatedPattern, authText)
|
|
if unauthenticated {
|
|
failure := readinessFailure(
|
|
StateUnauthenticated,
|
|
provider.ID, model.ID, profile.ID,
|
|
fmt.Sprintf("provider %q requires authentication", provider.ID),
|
|
ErrAuthenticationRequired,
|
|
)
|
|
failure.Command = provider.Command
|
|
failure.Version = base.Version
|
|
failure.Capabilities = base.Capabilities
|
|
return failure
|
|
}
|
|
if authErr != nil {
|
|
return d.probeFailure(base, "authentication", auth, authErr)
|
|
}
|
|
if provider.Authentication.SuccessPattern != "" &&
|
|
!matches(provider.Authentication.SuccessPattern, authText) {
|
|
return d.probeFailure(
|
|
base,
|
|
"authentication",
|
|
auth,
|
|
fmt.Errorf("success pattern did not match"),
|
|
)
|
|
}
|
|
|
|
if len(provider.ModelProbe.Args) > 0 {
|
|
models, modelErr := d.runProbe(
|
|
ctx, command, provider.ModelProbe.Args, provider.ModelProbe.TimeoutMS,
|
|
)
|
|
if modelErr != nil {
|
|
return d.probeFailure(base, "model", models, modelErr)
|
|
}
|
|
if !modelOutputContains(models.Output, model.Target) {
|
|
failure := readinessFailure(
|
|
StateUnsupportedModel,
|
|
provider.ID, model.ID, profile.ID,
|
|
fmt.Sprintf("provider %q does not report model target %q", provider.ID, model.Target),
|
|
ErrModelUnsupported,
|
|
)
|
|
failure.Command = provider.Command
|
|
failure.Version = base.Version
|
|
failure.Capabilities = base.Capabilities
|
|
return failure
|
|
}
|
|
}
|
|
|
|
base.State = StateReady
|
|
base.Detail = "installed and authenticated"
|
|
return base
|
|
}
|
|
|
|
func (d *Discoverer) runProbe(
|
|
parent context.Context,
|
|
command string,
|
|
args []string,
|
|
timeoutMS int,
|
|
) (RunResult, error) {
|
|
if timeoutMS == 0 {
|
|
timeoutMS = agentconfig.DefaultProbeTimeoutMS
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutMS)*time.Millisecond)
|
|
defer cancel()
|
|
result, err := d.runner.Run(ctx, command, args)
|
|
if ctx.Err() != nil {
|
|
return result, ctx.Err()
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func (d *Discoverer) probeFailure(
|
|
base Readiness,
|
|
probe string,
|
|
result RunResult,
|
|
err error,
|
|
) Readiness {
|
|
detail := fmt.Sprintf("%s probe failed", probe)
|
|
if output := diagnostic(result.Output); output != "" {
|
|
detail += ": " + output
|
|
} else if err != nil {
|
|
detail += ": " + diagnostic(err.Error())
|
|
}
|
|
failure := readinessFailure(
|
|
StateProbeError,
|
|
base.ProviderID, base.ModelID, base.ProfileID,
|
|
detail,
|
|
fmt.Errorf("%w: %v", ErrProbeFailed, err),
|
|
)
|
|
failure.Command = base.Command
|
|
failure.Version = base.Version
|
|
failure.Capabilities = base.Capabilities
|
|
return failure
|
|
}
|
|
|
|
func matches(expression, value string) bool {
|
|
if expression == "" {
|
|
return false
|
|
}
|
|
compiled := regexp.MustCompile(expression)
|
|
return compiled.MatchString(value)
|
|
}
|
|
|
|
func firstNonEmptyLine(output string) string {
|
|
for _, line := range strings.Split(diagnostic(output), "\n") {
|
|
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func modelOutputContains(output, target string) bool {
|
|
for _, line := range strings.Split(Redact(output), "\n") {
|
|
if strings.TrimSpace(line) == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mustResolve(cfg agentconfig.Catalog, profileID string) agentconfig.ResolvedProfile {
|
|
resolved, ok := cfg.ResolveProfile(profileID)
|
|
if !ok {
|
|
panic("validated catalog lost profile " + profileID)
|
|
}
|
|
return resolved
|
|
}
|