독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
860 lines
30 KiB
Go
860 lines
30 KiB
Go
package agentconfig
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const RuntimeConfigSchemaVersion = "1"
|
|
|
|
// RepoGlobalRuntimeConfig is the version-controlled, secret-free input owned
|
|
// by a project repository. The runtime only reads this document.
|
|
type RepoGlobalRuntimeConfig struct {
|
|
Version string `yaml:"version"`
|
|
Catalog Catalog `yaml:"catalog,omitempty"`
|
|
Defaults RuntimeDefaults `yaml:"defaults,omitempty"`
|
|
Selection SelectionPolicy `yaml:"selection,omitempty"`
|
|
Isolation IsolationPolicy `yaml:"isolation,omitempty"`
|
|
Retention RetentionPolicy `yaml:"retention,omitempty"`
|
|
}
|
|
|
|
// UserLocalRuntimeConfig is the device-owned input applied after the
|
|
// repository input. It deliberately has no credential or raw environment
|
|
// value fields.
|
|
type UserLocalRuntimeConfig struct {
|
|
Version string `yaml:"version"`
|
|
Device DeviceRuntimeConfig `yaml:"device"`
|
|
Override RuntimeConfigOverride `yaml:"override,omitempty"`
|
|
Projects map[string]ProjectRegistrationOverlay `yaml:"projects,omitempty"`
|
|
}
|
|
|
|
// RuntimeConfig is the fully merged configuration captured by a
|
|
// RuntimeSnapshot.
|
|
type RuntimeConfig struct {
|
|
Version string `yaml:"version"`
|
|
Revision string `yaml:"-"`
|
|
SourceRevisions SourceRevisions `yaml:"-"`
|
|
Catalog Catalog `yaml:"catalog,omitempty"`
|
|
Device DeviceRuntimeConfig `yaml:"device"`
|
|
Defaults RuntimeDefaults `yaml:"defaults,omitempty"`
|
|
Selection SelectionPolicy `yaml:"selection,omitempty"`
|
|
Isolation IsolationPolicy `yaml:"isolation,omitempty"`
|
|
Retention RetentionPolicy `yaml:"retention,omitempty"`
|
|
Projects map[string]ProjectRegistration `yaml:"projects,omitempty"`
|
|
}
|
|
|
|
// RuntimeDefaults contains scalar and map defaults. ProfileAliases is merged
|
|
// by key, with the user-local value winning for duplicate aliases.
|
|
type RuntimeDefaults struct {
|
|
DefaultProfile string `yaml:"default_profile,omitempty"`
|
|
AutoResumeInterrupted bool `yaml:"auto_resume_interrupted,omitempty"`
|
|
ProfileAliases map[string]string `yaml:"profile_aliases,omitempty"`
|
|
}
|
|
|
|
// RuntimeDefaultsOverride uses pointers for scalars so an explicit false or
|
|
// empty value remains distinguishable from an omitted value.
|
|
type RuntimeDefaultsOverride struct {
|
|
DefaultProfile *string `yaml:"default_profile,omitempty"`
|
|
AutoResumeInterrupted *bool `yaml:"auto_resume_interrupted,omitempty"`
|
|
ProfileAliases map[string]string `yaml:"profile_aliases,omitempty"`
|
|
}
|
|
|
|
// TargetRef is a provider/model/profile identity consumed by the shared
|
|
// selector. Profile is the runtime target; provider and model preserve the
|
|
// declared identity when supplied.
|
|
type TargetRef struct {
|
|
Provider string `yaml:"provider,omitempty"`
|
|
Model string `yaml:"model,omitempty"`
|
|
Profile string `yaml:"profile,omitempty"`
|
|
}
|
|
|
|
// SelectionPolicy is ordered. Rules are evaluated by the selector in their
|
|
// stored order; the config registry never sorts them.
|
|
type SelectionPolicy struct {
|
|
Version string `yaml:"version,omitempty"`
|
|
Revision string `yaml:"-"`
|
|
Timezone string `yaml:"timezone,omitempty"`
|
|
Default TargetRef `yaml:"default,omitempty"`
|
|
Rules []SelectionRule `yaml:"rules,omitempty"`
|
|
}
|
|
|
|
// SelectionPolicyOverride replaces Rules as a whole when rules is present,
|
|
// including when it is explicitly an empty array.
|
|
type SelectionPolicyOverride struct {
|
|
Timezone *string `yaml:"timezone,omitempty"`
|
|
Default *TargetRef `yaml:"default,omitempty"`
|
|
Rules *[]SelectionRule `yaml:"rules,omitempty"`
|
|
}
|
|
|
|
// SelectionRule is a strict policy input. Evaluation belongs to the
|
|
// agentpolicy package; this package validates and preserves rule order.
|
|
type SelectionRule struct {
|
|
ID string `yaml:"id"`
|
|
Match SelectionMatch `yaml:"match,omitempty"`
|
|
Target TargetRef `yaml:"target"`
|
|
}
|
|
|
|
// SelectionMatch contains the SDD-defined policy predicates without assigning
|
|
// evaluator semantics to them.
|
|
type SelectionMatch struct {
|
|
TimeWindows []SelectionTimeWindow `yaml:"time_windows,omitempty"`
|
|
QuotaStates []string `yaml:"quota_states,omitempty"`
|
|
MinRemainingToken *int64 `yaml:"min_remaining_tokens,omitempty"`
|
|
Agents []string `yaml:"agents,omitempty"`
|
|
Stages []string `yaml:"stages,omitempty"`
|
|
Lanes []string `yaml:"lanes,omitempty"`
|
|
MinGrade int `yaml:"min_grade,omitempty"`
|
|
MaxGrade int `yaml:"max_grade,omitempty"`
|
|
Capabilities []string `yaml:"capabilities,omitempty"`
|
|
FailureCodes []string `yaml:"failure_codes,omitempty"`
|
|
}
|
|
|
|
// SelectionTimeWindow is interpreted in SelectionPolicy.Timezone.
|
|
type SelectionTimeWindow struct {
|
|
Days []string `yaml:"days,omitempty"`
|
|
Start string `yaml:"start"`
|
|
End string `yaml:"end"`
|
|
}
|
|
|
|
// IsolationPolicy defines the default isolation mode and its ordered fallback
|
|
// modes. FallbackModes is another whole-replacement ordered array.
|
|
type IsolationPolicy struct {
|
|
DefaultMode string `yaml:"default_mode,omitempty"`
|
|
FallbackModes []string `yaml:"fallback_modes,omitempty"`
|
|
}
|
|
|
|
type IsolationPolicyOverride struct {
|
|
DefaultMode *string `yaml:"default_mode,omitempty"`
|
|
FallbackModes *[]string `yaml:"fallback_modes,omitempty"`
|
|
}
|
|
|
|
// RetentionPolicy contains non-negative local retention limits. Zero leaves a
|
|
// limit disabled.
|
|
type RetentionPolicy struct {
|
|
CompletedDays int `yaml:"completed_days,omitempty"`
|
|
BlockedDays int `yaml:"blocked_days,omitempty"`
|
|
MaxProjectLogRecords int `yaml:"max_project_log_records,omitempty"`
|
|
}
|
|
|
|
type RetentionPolicyOverride struct {
|
|
CompletedDays *int `yaml:"completed_days,omitempty"`
|
|
BlockedDays *int `yaml:"blocked_days,omitempty"`
|
|
MaxProjectLogRecords *int `yaml:"max_project_log_records,omitempty"`
|
|
}
|
|
|
|
// DeviceRuntimeConfig contains device-owned roots. StateRoot, OverlayRoot, and
|
|
// LogRoot are required absolute clean paths; TempRoot and CacheRoot are
|
|
// optional but must meet the same rule when set.
|
|
type DeviceRuntimeConfig struct {
|
|
StateRoot string `yaml:"state_root"`
|
|
OverlayRoot string `yaml:"overlay_root"`
|
|
LogRoot string `yaml:"log_root"`
|
|
TempRoot string `yaml:"temp_root,omitempty"`
|
|
CacheRoot string `yaml:"cache_root,omitempty"`
|
|
}
|
|
|
|
// RuntimeConfigOverride is applied field-by-field after its parent config.
|
|
// Scalar pointers replace scalars, maps merge local-wins, and ordered arrays
|
|
// replace rather than append.
|
|
type RuntimeConfigOverride struct {
|
|
Defaults RuntimeDefaultsOverride `yaml:"defaults,omitempty"`
|
|
Selection SelectionPolicyOverride `yaml:"selection,omitempty"`
|
|
Isolation IsolationPolicyOverride `yaml:"isolation,omitempty"`
|
|
Retention RetentionPolicyOverride `yaml:"retention,omitempty"`
|
|
}
|
|
|
|
// ProjectRegistrationOverlay is the user-local schema for one project.
|
|
type ProjectRegistrationOverlay struct {
|
|
Workspace string `yaml:"workspace"`
|
|
Enabled *bool `yaml:"enabled,omitempty"`
|
|
SelectedMilestone string `yaml:"selected_milestone,omitempty"`
|
|
Override RuntimeConfigOverride `yaml:"override,omitempty"`
|
|
}
|
|
|
|
// ProjectRegistration is an effective project configuration. It retains the
|
|
// project identity and the complete revision-pinned defaults/policies that
|
|
// apply to the registered workspace.
|
|
type ProjectRegistration struct {
|
|
ID string
|
|
Workspace string
|
|
Enabled bool
|
|
SelectedMilestone string
|
|
ConfigRevision string
|
|
Defaults RuntimeDefaults
|
|
Selection SelectionPolicy
|
|
Isolation IsolationPolicy
|
|
Retention RetentionPolicy
|
|
AutoResumeInterrupted bool
|
|
}
|
|
|
|
// SourceRevisions identifies the exact byte content of both configuration
|
|
// inputs.
|
|
type SourceRevisions struct {
|
|
RepoGlobal string
|
|
UserLocal string
|
|
}
|
|
|
|
// RuntimeSnapshot is immutable through its API. The merged configuration is
|
|
// private and every accessor returns a defensive deep copy.
|
|
type RuntimeSnapshot struct {
|
|
config RuntimeConfig
|
|
revision string
|
|
sources SourceRevisions
|
|
}
|
|
|
|
// LoadRuntimeConfig reads the repository input without opening it for writing,
|
|
// reads the user-local input, and returns one immutable merged snapshot.
|
|
func LoadRuntimeConfig(repoGlobalPath, userLocalPath string) (RuntimeSnapshot, error) {
|
|
repoGlobal, err := os.ReadFile(repoGlobalPath)
|
|
if err != nil {
|
|
return RuntimeSnapshot{}, fmt.Errorf("agentconfig: read repo-global runtime config: %w", err)
|
|
}
|
|
userLocal, err := os.ReadFile(userLocalPath)
|
|
if err != nil {
|
|
return RuntimeSnapshot{}, fmt.Errorf("agentconfig: read user-local runtime config: %w", err)
|
|
}
|
|
return LoadRuntimeConfigBytes(repoGlobal, userLocal)
|
|
}
|
|
|
|
// LoadRuntimeConfigBytes strictly decodes and composes one repo-global and one
|
|
// user-local document. It is pure and never writes either input.
|
|
func LoadRuntimeConfigBytes(repoGlobal, userLocal []byte) (RuntimeSnapshot, error) {
|
|
var global RepoGlobalRuntimeConfig
|
|
if err := decodeStrictRuntimeConfig(repoGlobal, "repo-global", &global); err != nil {
|
|
return RuntimeSnapshot{}, err
|
|
}
|
|
var local UserLocalRuntimeConfig
|
|
if err := decodeStrictRuntimeConfig(userLocal, "user-local", &local); err != nil {
|
|
return RuntimeSnapshot{}, err
|
|
}
|
|
|
|
normalizedGlobal, err := normalizeRepoGlobal(global)
|
|
if err != nil {
|
|
return RuntimeSnapshot{}, err
|
|
}
|
|
if err := validateUserLocal(local); err != nil {
|
|
return RuntimeSnapshot{}, err
|
|
}
|
|
merged, err := mergeRuntimeConfig(normalizedGlobal, local)
|
|
if err != nil {
|
|
return RuntimeSnapshot{}, err
|
|
}
|
|
|
|
sources := SourceRevisions{
|
|
RepoGlobal: digestRevision(repoGlobal),
|
|
UserLocal: digestRevision(userLocal),
|
|
}
|
|
revision := digestRevisionParts("runtime-config", sources.RepoGlobal, sources.UserLocal)
|
|
merged.Revision = revision
|
|
merged.SourceRevisions = sources
|
|
merged.Selection.Revision = digestRevisionParts("selection-policy", revision)
|
|
for projectID, project := range merged.Projects {
|
|
project.ConfigRevision = revision
|
|
project.Selection.Revision = digestRevisionParts("selection-policy", revision, projectID)
|
|
merged.Projects[projectID] = project
|
|
}
|
|
return RuntimeSnapshot{
|
|
config: cloneRuntimeConfig(merged),
|
|
revision: revision,
|
|
sources: sources,
|
|
}, nil
|
|
}
|
|
|
|
// Revision returns the immutable revision pinned to an invocation.
|
|
func (s RuntimeSnapshot) Revision() string {
|
|
return s.revision
|
|
}
|
|
|
|
// SourceRevisions returns the exact source revisions used by this snapshot.
|
|
func (s RuntimeSnapshot) SourceRevisions() SourceRevisions {
|
|
return s.sources
|
|
}
|
|
|
|
// Config returns a defensive deep copy of the merged runtime configuration.
|
|
func (s RuntimeSnapshot) Config() RuntimeConfig {
|
|
return cloneRuntimeConfig(s.config)
|
|
}
|
|
|
|
// Project returns a defensive copy of one effective project registration.
|
|
func (s RuntimeSnapshot) Project(projectID string) (ProjectRegistration, bool) {
|
|
project, ok := s.config.Projects[projectID]
|
|
if !ok {
|
|
return ProjectRegistration{}, false
|
|
}
|
|
return cloneProjectRegistration(project), true
|
|
}
|
|
|
|
func decodeStrictRuntimeConfig(data []byte, source string, destination any) error {
|
|
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
|
|
decoder.KnownFields(true)
|
|
if err := decoder.Decode(destination); err != nil {
|
|
return fmt.Errorf("agentconfig: decode %s runtime config: %w", source, err)
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
if err == nil {
|
|
return fmt.Errorf("agentconfig: %s runtime config must contain exactly one YAML document", source)
|
|
}
|
|
return fmt.Errorf("agentconfig: decode trailing %s runtime document: %w", source, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeRepoGlobal(global RepoGlobalRuntimeConfig) (RepoGlobalRuntimeConfig, error) {
|
|
if global.Version != RuntimeConfigSchemaVersion {
|
|
return RepoGlobalRuntimeConfig{}, fmt.Errorf("agentconfig: unsupported repo-global runtime config version %q", global.Version)
|
|
}
|
|
if catalogConfigured(global.Catalog) {
|
|
catalog, err := Normalize(global.Catalog)
|
|
if err != nil {
|
|
return RepoGlobalRuntimeConfig{}, fmt.Errorf("agentconfig: repo-global catalog: %w", err)
|
|
}
|
|
global.Catalog = catalog
|
|
}
|
|
if err := validateRuntimeDefaults("repo-global defaults", global.Defaults); err != nil {
|
|
return RepoGlobalRuntimeConfig{}, err
|
|
}
|
|
if global.Selection.Version == "" {
|
|
global.Selection.Version = RuntimeConfigSchemaVersion
|
|
}
|
|
if err := validateSelectionPolicy("repo-global selection", global.Selection); err != nil {
|
|
return RepoGlobalRuntimeConfig{}, err
|
|
}
|
|
if err := validateIsolationPolicy("repo-global isolation", global.Isolation); err != nil {
|
|
return RepoGlobalRuntimeConfig{}, err
|
|
}
|
|
if err := validateRetentionPolicy("repo-global retention", global.Retention); err != nil {
|
|
return RepoGlobalRuntimeConfig{}, err
|
|
}
|
|
return global, nil
|
|
}
|
|
|
|
func validateUserLocal(local UserLocalRuntimeConfig) error {
|
|
if local.Version != RuntimeConfigSchemaVersion {
|
|
return fmt.Errorf("agentconfig: unsupported user-local runtime config version %q", local.Version)
|
|
}
|
|
if err := validateDeviceConfig(local.Device); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRuntimeOverride("user-local override", local.Override); err != nil {
|
|
return err
|
|
}
|
|
for projectID, project := range local.Projects {
|
|
if err := validateID("project", projectID); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCleanAbsolutePath("project "+projectID+" workspace", project.Workspace, true); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRuntimeOverride("project "+projectID+" override", project.Override); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mergeRuntimeConfig(global RepoGlobalRuntimeConfig, local UserLocalRuntimeConfig) (RuntimeConfig, error) {
|
|
merged := RuntimeConfig{
|
|
Version: RuntimeConfigSchemaVersion,
|
|
Catalog: cloneCatalog(global.Catalog),
|
|
Device: local.Device,
|
|
Defaults: cloneRuntimeDefaults(global.Defaults),
|
|
Selection: cloneSelectionPolicy(global.Selection),
|
|
Isolation: cloneIsolationPolicy(global.Isolation),
|
|
Retention: global.Retention,
|
|
Projects: make(map[string]ProjectRegistration, len(local.Projects)),
|
|
}
|
|
applyRuntimeOverride(&merged.Defaults, &merged.Selection, &merged.Isolation, &merged.Retention, local.Override)
|
|
if err := validateMergedRuntimeConfig("merged runtime config", merged.Defaults, merged.Selection, merged.Isolation, merged.Retention); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if err := validateRuntimeCatalogBindings("merged runtime config", merged.Catalog, merged.Defaults, merged.Selection); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
|
|
for projectID, overlay := range local.Projects {
|
|
defaults := cloneRuntimeDefaults(merged.Defaults)
|
|
selection := cloneSelectionPolicy(merged.Selection)
|
|
isolation := cloneIsolationPolicy(merged.Isolation)
|
|
retention := merged.Retention
|
|
applyRuntimeOverride(&defaults, &selection, &isolation, &retention, overlay.Override)
|
|
if err := validateMergedRuntimeConfig("project "+projectID, defaults, selection, isolation, retention); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
if err := validateRuntimeCatalogBindings("project "+projectID, merged.Catalog, defaults, selection); err != nil {
|
|
return RuntimeConfig{}, err
|
|
}
|
|
enabled := true
|
|
if overlay.Enabled != nil {
|
|
enabled = *overlay.Enabled
|
|
}
|
|
merged.Projects[projectID] = ProjectRegistration{
|
|
ID: projectID,
|
|
Workspace: overlay.Workspace,
|
|
Enabled: enabled,
|
|
SelectedMilestone: overlay.SelectedMilestone,
|
|
Defaults: defaults,
|
|
Selection: selection,
|
|
Isolation: isolation,
|
|
Retention: retention,
|
|
AutoResumeInterrupted: defaults.AutoResumeInterrupted,
|
|
}
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
func applyRuntimeOverride(
|
|
defaults *RuntimeDefaults,
|
|
selection *SelectionPolicy,
|
|
isolation *IsolationPolicy,
|
|
retention *RetentionPolicy,
|
|
override RuntimeConfigOverride,
|
|
) {
|
|
if override.Defaults.DefaultProfile != nil {
|
|
defaults.DefaultProfile = *override.Defaults.DefaultProfile
|
|
}
|
|
if override.Defaults.AutoResumeInterrupted != nil {
|
|
defaults.AutoResumeInterrupted = *override.Defaults.AutoResumeInterrupted
|
|
}
|
|
if override.Defaults.ProfileAliases != nil {
|
|
if defaults.ProfileAliases == nil {
|
|
defaults.ProfileAliases = make(map[string]string, len(override.Defaults.ProfileAliases))
|
|
}
|
|
for alias, profile := range override.Defaults.ProfileAliases {
|
|
defaults.ProfileAliases[alias] = profile
|
|
}
|
|
}
|
|
if override.Selection.Timezone != nil {
|
|
selection.Timezone = *override.Selection.Timezone
|
|
}
|
|
if override.Selection.Default != nil {
|
|
selection.Default = *override.Selection.Default
|
|
}
|
|
if override.Selection.Rules != nil {
|
|
selection.Rules = cloneSelectionRules(*override.Selection.Rules)
|
|
}
|
|
if override.Isolation.DefaultMode != nil {
|
|
isolation.DefaultMode = *override.Isolation.DefaultMode
|
|
}
|
|
if override.Isolation.FallbackModes != nil {
|
|
isolation.FallbackModes = append([]string(nil), (*override.Isolation.FallbackModes)...)
|
|
}
|
|
if override.Retention.CompletedDays != nil {
|
|
retention.CompletedDays = *override.Retention.CompletedDays
|
|
}
|
|
if override.Retention.BlockedDays != nil {
|
|
retention.BlockedDays = *override.Retention.BlockedDays
|
|
}
|
|
if override.Retention.MaxProjectLogRecords != nil {
|
|
retention.MaxProjectLogRecords = *override.Retention.MaxProjectLogRecords
|
|
}
|
|
}
|
|
|
|
func validateMergedRuntimeConfig(
|
|
label string,
|
|
defaults RuntimeDefaults,
|
|
selection SelectionPolicy,
|
|
isolation IsolationPolicy,
|
|
retention RetentionPolicy,
|
|
) error {
|
|
if err := validateRuntimeDefaults(label+" defaults", defaults); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSelectionPolicy(label+" selection", selection); err != nil {
|
|
return err
|
|
}
|
|
if err := validateIsolationPolicy(label+" isolation", isolation); err != nil {
|
|
return err
|
|
}
|
|
return validateRetentionPolicy(label+" retention", retention)
|
|
}
|
|
|
|
func validateRuntimeOverride(label string, override RuntimeConfigOverride) error {
|
|
if override.Defaults.DefaultProfile != nil && *override.Defaults.DefaultProfile != "" {
|
|
if err := validateID(label+" default profile", *override.Defaults.DefaultProfile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for alias, profile := range override.Defaults.ProfileAliases {
|
|
if err := validateAlias(label, alias, profile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if override.Selection.Default != nil {
|
|
if err := validateTargetRef(label+" selection default", *override.Selection.Default); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if override.Selection.Rules != nil {
|
|
if err := validateSelectionRules(label+" selection rules", *override.Selection.Rules); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if override.Isolation.DefaultMode != nil {
|
|
if err := validateIsolationMode(label+" default isolation", *override.Isolation.DefaultMode, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if override.Isolation.FallbackModes != nil {
|
|
if err := validateIsolationModes(label+" isolation fallbacks", *override.Isolation.FallbackModes); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for field, value := range map[string]*int{
|
|
"completed_days": override.Retention.CompletedDays,
|
|
"blocked_days": override.Retention.BlockedDays,
|
|
"max_project_log_records": override.Retention.MaxProjectLogRecords,
|
|
} {
|
|
if value != nil && *value < 0 {
|
|
return fmt.Errorf("agentconfig: %s retention %s must be non-negative", label, field)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRuntimeDefaults(label string, defaults RuntimeDefaults) error {
|
|
if defaults.DefaultProfile != "" {
|
|
if err := validateID(label+" default profile", defaults.DefaultProfile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for alias, profile := range defaults.ProfileAliases {
|
|
if err := validateAlias(label, alias, profile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRuntimeCatalogBindings(
|
|
label string,
|
|
catalog Catalog,
|
|
defaults RuntimeDefaults,
|
|
selection SelectionPolicy,
|
|
) error {
|
|
if !catalogConfigured(catalog) {
|
|
return nil
|
|
}
|
|
profileIDs := make([]string, 0, 2+len(defaults.ProfileAliases)+len(selection.Rules))
|
|
if defaults.DefaultProfile != "" {
|
|
profileIDs = append(profileIDs, defaults.DefaultProfile)
|
|
}
|
|
for _, profileID := range defaults.ProfileAliases {
|
|
profileIDs = append(profileIDs, profileID)
|
|
}
|
|
for _, profileID := range profileIDs {
|
|
if _, ok := catalog.ResolveProfile(profileID); !ok {
|
|
return fmt.Errorf("agentconfig: %s references unknown profile %q", label, profileID)
|
|
}
|
|
}
|
|
if err := validateTargetCatalogBinding(label+" selection default", catalog, selection.Default); err != nil {
|
|
return err
|
|
}
|
|
for _, rule := range selection.Rules {
|
|
if err := validateTargetCatalogBinding("selection rule "+rule.ID+" target", catalog, rule.Target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTargetCatalogBinding(label string, catalog Catalog, target TargetRef) error {
|
|
if target.Profile == "" {
|
|
return nil
|
|
}
|
|
resolved, ok := catalog.ResolveProfile(target.Profile)
|
|
if !ok {
|
|
return fmt.Errorf("agentconfig: %s references unknown profile %q", label, target.Profile)
|
|
}
|
|
if target.Provider != "" && target.Provider != resolved.Provider.ID {
|
|
return fmt.Errorf(
|
|
"agentconfig: %s provider %q does not match profile provider %q",
|
|
label,
|
|
target.Provider,
|
|
resolved.Provider.ID,
|
|
)
|
|
}
|
|
if target.Model != "" && target.Model != resolved.Model.ID {
|
|
return fmt.Errorf(
|
|
"agentconfig: %s model %q does not match profile model %q",
|
|
label,
|
|
target.Model,
|
|
resolved.Model.ID,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAlias(label, alias, profile string) error {
|
|
if err := validateID(label+" alias", alias); err != nil {
|
|
return err
|
|
}
|
|
if err := validateID(label+" alias profile", profile); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSelectionPolicy(label string, policy SelectionPolicy) error {
|
|
if policy.Version != RuntimeConfigSchemaVersion {
|
|
return fmt.Errorf("agentconfig: %s has unsupported version %q", label, policy.Version)
|
|
}
|
|
if err := validateTargetRef(label+" default", policy.Default); err != nil {
|
|
return err
|
|
}
|
|
return validateSelectionRules(label+" rules", policy.Rules)
|
|
}
|
|
|
|
func validateSelectionRules(label string, rules []SelectionRule) error {
|
|
seen := make(map[string]struct{}, len(rules))
|
|
for _, rule := range rules {
|
|
if err := validateID("selection rule", rule.ID); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := seen[rule.ID]; exists {
|
|
return fmt.Errorf("agentconfig: %s repeats rule id %q", label, rule.ID)
|
|
}
|
|
seen[rule.ID] = struct{}{}
|
|
if err := validateTargetRef("selection rule "+rule.ID+" target", rule.Target); err != nil {
|
|
return err
|
|
}
|
|
if rule.Target.Profile == "" {
|
|
return fmt.Errorf("agentconfig: selection rule %q target.profile is required", rule.ID)
|
|
}
|
|
if rule.Match.MinRemainingToken != nil && *rule.Match.MinRemainingToken < 0 {
|
|
return fmt.Errorf("agentconfig: selection rule %q min_remaining_tokens must be non-negative", rule.ID)
|
|
}
|
|
if rule.Match.MinGrade < 0 || rule.Match.MaxGrade < 0 {
|
|
return fmt.Errorf("agentconfig: selection rule %q grades must be non-negative", rule.ID)
|
|
}
|
|
if rule.Match.MinGrade != 0 && rule.Match.MaxGrade != 0 && rule.Match.MinGrade > rule.Match.MaxGrade {
|
|
return fmt.Errorf("agentconfig: selection rule %q min_grade exceeds max_grade", rule.ID)
|
|
}
|
|
for _, window := range rule.Match.TimeWindows {
|
|
if strings.TrimSpace(window.Start) == "" || strings.TrimSpace(window.End) == "" {
|
|
return fmt.Errorf("agentconfig: selection rule %q time window start and end are required", rule.ID)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTargetRef(label string, target TargetRef) error {
|
|
if target.Profile == "" && (target.Provider != "" || target.Model != "") {
|
|
return fmt.Errorf("agentconfig: %s profile is required when provider or model is set", label)
|
|
}
|
|
for field, value := range map[string]string{
|
|
"provider": target.Provider,
|
|
"model": target.Model,
|
|
"profile": target.Profile,
|
|
} {
|
|
if value != "" {
|
|
if err := validateID(label+" "+field, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateIsolationPolicy(label string, policy IsolationPolicy) error {
|
|
if err := validateIsolationMode(label+" default_mode", policy.DefaultMode, false); err != nil {
|
|
return err
|
|
}
|
|
return validateIsolationModes(label+" fallback_modes", policy.FallbackModes)
|
|
}
|
|
|
|
func validateIsolationModes(label string, modes []string) error {
|
|
seen := make(map[string]struct{}, len(modes))
|
|
for _, mode := range modes {
|
|
if err := validateIsolationMode(label, mode, true); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := seen[mode]; exists {
|
|
return fmt.Errorf("agentconfig: %s repeats mode %q", label, mode)
|
|
}
|
|
seen[mode] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateIsolationMode(label, mode string, required bool) error {
|
|
if mode == "" && !required {
|
|
return nil
|
|
}
|
|
switch mode {
|
|
case "overlay", "worktree", "clone":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("agentconfig: %s has unsupported mode %q", label, mode)
|
|
}
|
|
}
|
|
|
|
func validateRetentionPolicy(label string, retention RetentionPolicy) error {
|
|
if retention.CompletedDays < 0 || retention.BlockedDays < 0 || retention.MaxProjectLogRecords < 0 {
|
|
return fmt.Errorf("agentconfig: %s values must be non-negative", label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDeviceConfig(device DeviceRuntimeConfig) error {
|
|
for label, path := range map[string]string{
|
|
"state_root": device.StateRoot,
|
|
"overlay_root": device.OverlayRoot,
|
|
"log_root": device.LogRoot,
|
|
} {
|
|
if err := validateCleanAbsolutePath("device "+label, path, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for label, path := range map[string]string{
|
|
"temp_root": device.TempRoot,
|
|
"cache_root": device.CacheRoot,
|
|
} {
|
|
if err := validateCleanAbsolutePath("device "+label, path, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCleanAbsolutePath(label, path string, required bool) error {
|
|
if path == "" && !required {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("agentconfig: %s is required", label)
|
|
}
|
|
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
|
return fmt.Errorf("agentconfig: %s must be an absolute clean path", label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func catalogConfigured(catalog Catalog) bool {
|
|
return catalog.Version != "" || len(catalog.Providers) != 0 || len(catalog.Models) != 0 || len(catalog.Profiles) != 0
|
|
}
|
|
|
|
func digestRevision(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func digestRevisionParts(parts ...string) string {
|
|
hash := sha256.New()
|
|
var length [8]byte
|
|
for _, part := range parts {
|
|
binary.BigEndian.PutUint64(length[:], uint64(len(part)))
|
|
_, _ = hash.Write(length[:])
|
|
_, _ = hash.Write([]byte(part))
|
|
}
|
|
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
func cloneRuntimeConfig(config RuntimeConfig) RuntimeConfig {
|
|
out := config
|
|
out.Catalog = cloneCatalog(config.Catalog)
|
|
out.Defaults = cloneRuntimeDefaults(config.Defaults)
|
|
out.Selection = cloneSelectionPolicy(config.Selection)
|
|
out.Isolation = cloneIsolationPolicy(config.Isolation)
|
|
if config.Projects != nil {
|
|
out.Projects = make(map[string]ProjectRegistration, len(config.Projects))
|
|
for id, project := range config.Projects {
|
|
out.Projects[id] = cloneProjectRegistration(project)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneProjectRegistration(project ProjectRegistration) ProjectRegistration {
|
|
out := project
|
|
out.Defaults = cloneRuntimeDefaults(project.Defaults)
|
|
out.Selection = cloneSelectionPolicy(project.Selection)
|
|
out.Isolation = cloneIsolationPolicy(project.Isolation)
|
|
return out
|
|
}
|
|
|
|
func cloneRuntimeDefaults(defaults RuntimeDefaults) RuntimeDefaults {
|
|
out := defaults
|
|
if defaults.ProfileAliases != nil {
|
|
out.ProfileAliases = make(map[string]string, len(defaults.ProfileAliases))
|
|
for alias, profile := range defaults.ProfileAliases {
|
|
out.ProfileAliases[alias] = profile
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneSelectionPolicy(policy SelectionPolicy) SelectionPolicy {
|
|
out := policy
|
|
out.Rules = cloneSelectionRules(policy.Rules)
|
|
return out
|
|
}
|
|
|
|
func cloneSelectionRules(rules []SelectionRule) []SelectionRule {
|
|
if rules == nil {
|
|
return nil
|
|
}
|
|
out := make([]SelectionRule, len(rules))
|
|
for index, rule := range rules {
|
|
out[index] = rule
|
|
out[index].Match.TimeWindows = append([]SelectionTimeWindow(nil), rule.Match.TimeWindows...)
|
|
for windowIndex := range out[index].Match.TimeWindows {
|
|
out[index].Match.TimeWindows[windowIndex].Days = append(
|
|
[]string(nil),
|
|
rule.Match.TimeWindows[windowIndex].Days...,
|
|
)
|
|
}
|
|
out[index].Match.QuotaStates = append([]string(nil), rule.Match.QuotaStates...)
|
|
out[index].Match.Agents = append([]string(nil), rule.Match.Agents...)
|
|
out[index].Match.Stages = append([]string(nil), rule.Match.Stages...)
|
|
out[index].Match.Lanes = append([]string(nil), rule.Match.Lanes...)
|
|
out[index].Match.Capabilities = append([]string(nil), rule.Match.Capabilities...)
|
|
out[index].Match.FailureCodes = append([]string(nil), rule.Match.FailureCodes...)
|
|
if rule.Match.MinRemainingToken != nil {
|
|
value := *rule.Match.MinRemainingToken
|
|
out[index].Match.MinRemainingToken = &value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneIsolationPolicy(policy IsolationPolicy) IsolationPolicy {
|
|
out := policy
|
|
out.FallbackModes = append([]string(nil), policy.FallbackModes...)
|
|
return out
|
|
}
|
|
|
|
func cloneCatalog(catalog Catalog) Catalog {
|
|
out := catalog
|
|
if catalog.Providers != nil {
|
|
out.Providers = make([]Provider, len(catalog.Providers))
|
|
for index, provider := range catalog.Providers {
|
|
out.Providers[index] = provider
|
|
out.Providers[index].VersionProbe.Args = append([]string(nil), provider.VersionProbe.Args...)
|
|
out.Providers[index].Authentication.Args = append([]string(nil), provider.Authentication.Args...)
|
|
out.Providers[index].ModelProbe.Args = append([]string(nil), provider.ModelProbe.Args...)
|
|
out.Providers[index].Capabilities = append([]string(nil), provider.Capabilities...)
|
|
}
|
|
}
|
|
out.Models = append([]Model(nil), catalog.Models...)
|
|
if catalog.Profiles != nil {
|
|
out.Profiles = make([]Profile, len(catalog.Profiles))
|
|
for index, profile := range catalog.Profiles {
|
|
out.Profiles[index] = profile
|
|
out.Profiles[index].Args = append([]string(nil), profile.Args...)
|
|
out.Profiles[index].ResumeArgs = append([]string(nil), profile.ResumeArgs...)
|
|
out.Profiles[index].Env = append([]string(nil), profile.Env...)
|
|
out.Profiles[index].Capabilities = append([]string(nil), profile.Capabilities...)
|
|
}
|
|
}
|
|
return out
|
|
}
|