package config import ( "errors" "fmt" "os" "strings" "time" "gopkg.in/yaml.v3" "github.com/burrow/forgejo-nsc/internal/forgejo" ) // Duration wraps time.Duration to support YAML unmarshalling from strings. type Duration struct { time.Duration } // UnmarshalYAML implements yaml.v3 unmarshalling for Duration. func (d *Duration) UnmarshalYAML(value *yaml.Node) error { switch value.Tag { case "!!int": var seconds int64 if err := value.Decode(&seconds); err != nil { return err } d.Duration = time.Duration(seconds) * time.Second return nil default: parsed, err := time.ParseDuration(value.Value) if err != nil { return err } d.Duration = parsed return nil } } // MarshalYAML implements yaml.v3 marshalling. func (d Duration) MarshalYAML() (any, error) { return d.Duration.String(), nil } type Config struct { Listen string `yaml:"listen"` Forgejo ForgejoConfig `yaml:"forgejo"` Namespace NamespaceConfig `yaml:"namespace"` Runner RunnerConfig `yaml:"runner"` } type CacheVolumeConfig struct { Tag string `yaml:"tag"` MountPoint string `yaml:"mount_point"` SizeGb int64 `yaml:"size_gb"` } type ForgejoConfig struct { BaseURL string `yaml:"base_url"` // InstanceURL is the URL runners should use when registering with Forgejo. // This must be reachable from the spawned runner (e.g. the public URL like // https://git.burrow.net), and may differ from BaseURL (which can be a local // loopback URL on the forge host). InstanceURL string `yaml:"instance_url"` Token string `yaml:"token"` DefaultScope ScopeConfig `yaml:"default_scope"` DefaultLabels []string `yaml:"default_labels"` Timeout Duration `yaml:"timeout"` ExtraHeaders yaml.Node `yaml:"extra_headers"` } type ScopeConfig struct { Level string `yaml:"level"` Owner string `yaml:"owner,omitempty"` Name string `yaml:"name,omitempty"` } type NamespaceConfig struct { NSCBinary string `yaml:"nsc_binary"` // ComputeBaseURL is the Namespace Cloud Compute API endpoint (Connect RPC base URL). // This is used for macOS runners, since NSC "run" is container-based (Linux-only). // Example: "https://ord4.compute.namespaceapis.com" ComputeBaseURL string `yaml:"compute_base_url"` Image string `yaml:"image"` MachineType string `yaml:"machine_type"` // MacosBaseImageID selects which macOS base image to use (e.g. "tahoe"). MacosBaseImageID string `yaml:"macos_base_image_id"` // MacosMachineArch is the architecture used for macOS instances (typically "arm64"). MacosMachineArch string `yaml:"macos_machine_arch"` Duration Duration `yaml:"duration"` WorkDir string `yaml:"workdir"` MaxParallel int64 `yaml:"max_parallel"` Environment []string `yaml:"environment"` AllowLabels []string `yaml:"allow_labels"` AllowScopes []string `yaml:"allow_scopes"` Network string `yaml:"network"` InstanceTags []string `yaml:"instance_tags"` LinuxCachePath string `yaml:"linux_cache_path"` LinuxCacheVolumes []CacheVolumeConfig `yaml:"linux_cache_volumes"` MacosCachePath string `yaml:"macos_cache_path"` MacosCacheVolumes []CacheVolumeConfig `yaml:"macos_cache_volumes"` } type RunnerConfig struct { NamePrefix string `yaml:"name_prefix"` Executor string `yaml:"executor"` } func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } func (c *Config) Validate() error { if c.Listen == "" { c.Listen = ":8080" } if c.Runner.NamePrefix == "" { c.Runner.NamePrefix = "nscloud-" } if c.Runner.Executor == "" { c.Runner.Executor = "shell" } if c.Forgejo.BaseURL == "" { return errors.New("forgejo.base_url is required") } if c.Forgejo.InstanceURL == "" { // Backwards-compatible default: assume runners can reach the same URL. c.Forgejo.InstanceURL = c.Forgejo.BaseURL } if c.Forgejo.Token == "" { return errors.New("forgejo.token is required") } if c.Forgejo.Timeout.Duration == 0 { c.Forgejo.Timeout.Duration = 30 * time.Second } if _, err := c.Forgejo.DefaultScope.ToScope(); err != nil { return err } if c.Namespace.NSCBinary == "" { c.Namespace.NSCBinary = "nsc" } if c.Namespace.Image == "" { c.Namespace.Image = "code.forgejo.org/forgejo/runner:11" } if c.Namespace.MacosBaseImageID == "" { c.Namespace.MacosBaseImageID = "tahoe" } if c.Namespace.MacosMachineArch == "" { c.Namespace.MacosMachineArch = "arm64" } if c.Namespace.Duration.Duration == 0 { c.Namespace.Duration.Duration = 30 * time.Minute } if c.Namespace.MaxParallel <= 0 { c.Namespace.MaxParallel = 4 } if c.Namespace.LinuxCachePath == "" { c.Namespace.LinuxCachePath = "/var/cache/burrow" } if len(c.Namespace.LinuxCacheVolumes) == 0 { c.Namespace.LinuxCacheVolumes = []CacheVolumeConfig{ { Tag: "burrow-forgejo-linux-nix-v2", MountPoint: "/nix", SizeGb: 80, }, { Tag: "burrow-forgejo-linux-cache-v2", MountPoint: c.Namespace.LinuxCachePath, SizeGb: 80, }, } } if c.Namespace.MacosCachePath == "" { c.Namespace.MacosCachePath = "/Users/runner/.cache/burrow" } if len(c.Namespace.MacosCacheVolumes) == 0 { c.Namespace.MacosCacheVolumes = []CacheVolumeConfig{ { Tag: "burrow-forgejo-macos-shared-v1", MountPoint: c.Namespace.MacosCachePath + "/shared", SizeGb: 80, }, { Tag: "burrow-forgejo-macos-macos-v1", MountPoint: c.Namespace.MacosCachePath + "/lane/macos", SizeGb: 80, }, { Tag: "burrow-forgejo-macos-ios-simulator-v1", MountPoint: c.Namespace.MacosCachePath + "/lane/ios-simulator", SizeGb: 80, }, } } for _, volume := range append(append([]CacheVolumeConfig{}, c.Namespace.LinuxCacheVolumes...), c.Namespace.MacosCacheVolumes...) { if strings.TrimSpace(volume.Tag) == "" { return errors.New("namespace cache volume tag is required") } if strings.TrimSpace(volume.MountPoint) == "" { return fmt.Errorf("namespace cache volume %q mount_point is required", volume.Tag) } if volume.SizeGb <= 0 { return fmt.Errorf("namespace cache volume %q size_gb must be positive", volume.Tag) } } return nil } func (s ScopeConfig) ToScope() (forgejo.Scope, error) { level := forgejo.ScopeLevel(strings.ToLower(s.Level)) switch level { case forgejo.ScopeInstance: return forgejo.Scope{Level: level}, nil case forgejo.ScopeOrganization: if s.Owner == "" { return forgejo.Scope{}, errors.New("forgejo default scope requires owner for organization level") } return forgejo.Scope{Level: level, Owner: s.Owner}, nil case forgejo.ScopeRepository: if s.Owner == "" || s.Name == "" { return forgejo.Scope{}, errors.New("forgejo default scope requires owner and name for repository level") } return forgejo.Scope{Level: level, Owner: s.Owner, Name: s.Name}, nil default: return forgejo.Scope{}, fmt.Errorf("unknown scope level %q", s.Level) } }