From 0cb3a838eebaa49fabf532736b78182c69c5ed7a Mon Sep 17 00:00:00 2001 From: xinyangli Date: Fri, 20 Dec 2024 19:20:45 +0800 Subject: [PATCH 1/4] Add Garnix executor - add `executor` option in config and nixos module - evaluate and fetch build result from garnix with minimal memory footprint - add test and doc for executors --- cmd/run.go | 21 ++- docs/generated-module-options.md | 152 ++++++++++++++++++++++ go.mod | 4 +- go.sum | 2 - internal/config/config.go | 10 ++ internal/config/config_test.go | 3 + internal/config/configuration.yaml | 2 + internal/executor/garnix.go | 197 +++++++++++++++++++++++++++++ internal/executor/utils.go | 13 ++ internal/types/types.go | 39 ++++-- nix/comin-config.nix | 1 + nix/module-options.nix | 55 ++++++++ 12 files changed, 476 insertions(+), 23 deletions(-) create mode 100644 internal/executor/garnix.go diff --git a/cmd/run.go b/cmd/run.go index c354b076..8ca3d4a4 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -54,14 +54,23 @@ var runCmd = &cobra.Command{ } var executor executorPkg.Executor - switch cfg.RepositoryType { - case "flake": - executor, err = executorPkg.NewNixOSFlake() + if cfg.ExecutorConfig.Type == "garnix" { + systemAttr := "nixosConfigurations" if runtime.GOOS == "darwin" { - executor, err = executorPkg.NewNixDarwinFlake() + systemAttr = "darwinConfigurations" + } + executor, err = executorPkg.NewGarnixExecutor(cfg.ExecutorConfig.GarnixConfig, systemAttr) + } else { + switch cfg.RepositoryType { + case "flake": + if runtime.GOOS == "darwin" { + executor, err = executorPkg.NewNixDarwinFlake() + } else { + executor, err = executorPkg.NewNixOSFlake() + } + case "nix": + executor, err = executorPkg.NewNixOSNix() } - case "nix": - executor, err = executorPkg.NewNixOSNix() } if err != nil { logrus.Errorf("Failed to create the executor: %s", err) diff --git a/docs/generated-module-options.md b/docs/generated-module-options.md index ae2bf298..ad3212a2 100644 --- a/docs/generated-module-options.md +++ b/docs/generated-module-options.md @@ -252,6 +252,158 @@ string +## services\.comin\.executor + + + +Select which executor will be used for evaluating and building the system configuration\. + +The ` garnix ` executor delegates evaluation and building to garnix\.io and fetches +the result from its binary cache\. For this to work, the user must add +` cache.garnix.io ` to ` nix.settings.substituters ` and the corresponding +` cache.garnix.io-1:... ` key to ` nix.settings.trusted-public-keys `\. + + + +*Type:* +submodule + + + +*Default:* + +```nix +{ } +``` + + + +## services\.comin\.executor\.garnix + + + +Configuration for the Garnix executor\. + + + +*Type:* +submodule + + + +*Default:* + +```nix +{ } +``` + + + +## services\.comin\.executor\.garnix\.baseUrl + + + +Base URL for the Garnix API\. Defaults to https://garnix\.io/ when empty\. + + + +*Type:* +string + + + +*Default:* + +```nix +"" +``` + + + +## services\.comin\.executor\.garnix\.cacheUrl + + + +URL of the Garnix binary cache\. Defaults to https://cache\.garnix\.io/ when empty\. + + + +*Type:* +string + + + +*Default:* + +```nix +"" +``` + + + +## services\.comin\.executor\.garnix\.cache_size + + + +LRU cache size for drvPath -> outPath mappings\. Defaults to 2 when 0\. + + + +*Type:* +signed integer + + + +*Default:* + +```nix +0 +``` + + + +## services\.comin\.executor\.garnix\.retry_interval + + + +Polling interval (in seconds) when waiting for a Garnix build\. Defaults to 60 when 0\. + + + +*Type:* +signed integer + + + +*Default:* + +```nix +0 +``` + + + +## services\.comin\.executor\.type + + + +Type of executor to use (nix or garnix)\. + + + +*Type:* +one of “nix”, “garnix” + + + +*Default:* + +```nix +"nix" +``` + + + ## services\.comin\.exporter diff --git a/go.mod b/go.mod index a405b6c5..36c0e82b 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.25.0 require ( charm.land/lipgloss/v2 v2.0.2 github.com/ProtonMail/go-crypto v1.1.5 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/dustin/go-humanize v1.0.1 github.com/gen2brain/beeep v0.11.2 github.com/go-co-op/gocron/v2 v2.11.0 github.com/go-git/go-git/v5 v5.11.0 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.19.0 github.com/sirupsen/logrus v1.9.3 @@ -27,7 +29,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect @@ -48,7 +49,6 @@ require ( github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/go.sum b/go.sum index 68b5548e..2455f8bf 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqx github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= diff --git a/internal/config/config.go b/internal/config/config.go index 8d0a4752..3884500a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,16 @@ func Read(path string) (config types.Configuration, err error) { if !slices.Contains(supportedRepositoryTypes, config.RepositoryType) { return config, fmt.Errorf("config: repository type is '%s' while it be one of '%s'", config.RepositoryType, supportedRepositoryTypes) } + if config.ExecutorConfig.Type == "" { + config.ExecutorConfig.Type = "nix" + } + supportedExecutorTypes := []string{"nix", "garnix"} + if !slices.Contains(supportedExecutorTypes, config.ExecutorConfig.Type) { + return config, fmt.Errorf("config: executor type is '%s' while it must be one of '%s'", config.ExecutorConfig.Type, supportedExecutorTypes) + } + if config.ExecutorConfig.Type == "garnix" && config.RepositoryType != "flake" { + return config, fmt.Errorf("config: executor type 'garnix' requires repository_type 'flake', got '%s'", config.RepositoryType) + } if config.Grpc.UnixSocketPath == "" { config.Grpc.UnixSocketPath = filepath.Join(config.StateDir, "grpc.sock") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4952591d..c054007c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,9 @@ func TestConfig(t *testing.T) { Grpc: types.Grpc{ UnixSocketPath: "/var/lib/comin/grpc.sock", }, + ExecutorConfig: types.ExecutorConfig{ + Type: "nix", + }, } config, err := Read(configPath) assert.Nil(t, err) diff --git a/internal/config/configuration.yaml b/internal/config/configuration.yaml index cf32200c..c8b05791 100644 --- a/internal/config/configuration.yaml +++ b/internal/config/configuration.yaml @@ -20,3 +20,5 @@ branches: protected: false poller: period: 10 +executor: + type: nix diff --git a/internal/executor/garnix.go b/internal/executor/garnix.go new file mode 100644 index 00000000..2097d4ea --- /dev/null +++ b/internal/executor/garnix.go @@ -0,0 +1,197 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/golang/groupcache/lru" + "github.com/nlewo/comin/internal/types" + "github.com/nlewo/comin/internal/utils" + "github.com/sirupsen/logrus" +) + +// Garnix evaluates and fetches the build result of a flake-based +// configuration from garnix.io with a minimal local memory footprint: +// it relies on garnix.io's CI build artifacts and pulls them from the +// substituter (e.g. cache.garnix.io) at deploy time. +type Garnix struct { + baseUrl url.URL + cacheUrl url.URL + retryInterval time.Duration + + // systemAttr is "nixosConfigurations" or "darwinConfigurations" and + // determines the expected packageType from the garnix API and how + // platform-specific helpers (reboot detection, machine-id) dispatch. + systemAttr string + + drv2Out lru.Cache +} + +func NewGarnixExecutor(config types.GarnixConfig, systemAttr string) (g *Garnix, err error) { + var baseUrl *url.URL + var cacheUrl *url.URL + if config.BaseUrl == "" { + baseUrl, _ = url.Parse("https://garnix.io/") + } else { + baseUrl, err = url.Parse(config.BaseUrl) + if err != nil { + return nil, err + } + } + + if config.CacheUrl == "" { + cacheUrl, _ = url.Parse("https://cache.garnix.io/") + } else { + cacheUrl, err = url.Parse(config.CacheUrl) + if err != nil { + return nil, err + } + } + + if config.CacheSize == 0 { + config.CacheSize = 2 + } + + if config.RetryInterval == 0 { + config.RetryInterval = 60 + } + + g = &Garnix{ + baseUrl: *baseUrl, + cacheUrl: *cacheUrl, + retryInterval: time.Duration(config.RetryInterval) * time.Second, + systemAttr: systemAttr, + drv2Out: *lru.New(config.CacheSize), + } + return +} + +type GarnixOutPath struct { + Out string `json:"out"` +} + +type GarnixBuild struct { + Id string `json:"id"` + DrvPath string `json:"drv_path"` + OutPath GarnixOutPath `json:"output_paths"` + PackageType string `json:"package_type"` + Package string `json:"package"` + UploadedToCache bool `json:"uploaded_to_cache"` + EndTime string `json:"end_time"` + Status string `json:"status"` +} + +type GarnixCommit struct { + GarnixBuilds []GarnixBuild `json:"builds"` +} + +func (g *Garnix) expectedPackageType() string { + if g.systemAttr == "darwinConfigurations" { + return "darwinConfiguration" + } + return "nixosConfiguration" +} + +// Eval polls the garnix API for a build matching the given commit and +// hostname, blocking until the build artifact has been uploaded to the +// cache. The returned machineId is always empty: deriving the expected +// machine-id would require a local flake evaluation, which defeats the +// purpose of the Garnix executor. +func (g *Garnix) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { + machineId = "" + if commitId == "" { + err = errors.New("garnix: commitId is required") + return + } + + expectedPackageType := g.expectedPackageType() + + for { + commitUrl := g.baseUrl.JoinPath("/api/commits/", commitId) + logrus.Infof("garnix: fetching commit result from %s", commitUrl) + + var resp *http.Response + resp, err = http.Get(commitUrl.String()) + if err != nil { + return + } + + var commitInfo GarnixCommit + decodeErr := json.NewDecoder(resp.Body).Decode(&commitInfo) + if cerr := resp.Body.Close(); cerr != nil { + logrus.Warnf("garnix: failed to close response body: %v", cerr) + } + if decodeErr != nil { + err = decodeErr + return + } + + for _, build := range commitInfo.GarnixBuilds { + if build.PackageType != expectedPackageType || build.Package != hostname { + continue + } + if build.EndTime != "" && build.Status != "Success" { + err = fmt.Errorf("garnix: build for %s/%s failed (status=%s)", commitId, hostname, build.Status) + return + } + if !build.UploadedToCache { + logrus.Infof("garnix: build for %s/%s not uploaded to cache yet, retrying...", commitId, hostname) + break + } + drvPath = build.DrvPath + outPath = build.OutPath.Out + g.drv2Out.Add(drvPath, outPath) + return + } + + select { + case <-time.After(g.retryInterval): + case <-ctx.Done(): + err = ctx.Err() + return + } + } +} + +func (g *Garnix) Build(ctx context.Context, drvPath string) (err error) { + logrus.Infof("garnix: fetching build for %s", drvPath) + value, ok := g.drv2Out.Get(drvPath) + if !ok { + return errors.New("garnix: build called before eval") + } + outPath, ok := value.(string) + if !ok { + return errors.New("garnix: drv2Out cache contained a non-string value") + } + return fetchBuild(ctx, outPath) +} + +func (g *Garnix) Deploy(ctx context.Context, outPath, operation string, profilePaths []string) (needToRestartComin bool, profilePath string, err error) { + return deploy(ctx, outPath, operation, g.systemAttr, profilePaths) +} + +func (g *Garnix) IsStorePathExist(storePath string) bool { + return isStorePathExist(storePath) +} + +func (g *Garnix) NeedToReboot(outPath, operation string) bool { + if g.systemAttr == "darwinConfigurations" { + // See NixFlakeLocal.NeedToReboot: Darwin lacks the + // /run/current-system vs /run/booted-system mechanism, so + // conservatively assume no reboot is needed. + return false + } + return utils.NeedToRebootLinux(outPath, operation) +} + +func (g *Garnix) ReadMachineId() (machineId string, err error) { + if g.systemAttr == "darwinConfigurations" { + return utils.ReadMachineIdDarwin() + } + return utils.ReadMachineIdLinux() +} diff --git a/internal/executor/utils.go b/internal/executor/utils.go index 288afebe..0c7e0cff 100644 --- a/internal/executor/utils.go +++ b/internal/executor/utils.go @@ -352,3 +352,16 @@ func isStorePathExist(storePath string) bool { } return true } + +// fetchBuild fetches the build output from the Nix binary cache +// instead of evaluating locally. The substituter (e.g. cache.garnix.io) +// must be configured in nix.conf for the fetch to succeed. +func fetchBuild(ctx context.Context, outPath string) (err error) { + args := []string{ + "build", + outPath, + "-L", + "--no-link", + } + return runNixFlakeCommand(ctx, args, os.Stdout, os.Stderr) +} diff --git a/internal/types/types.go b/internal/types/types.go index 6bcbab66..bb4d0448 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -62,22 +62,35 @@ type Retention struct { DeploymentAnyCapacity int `yaml:"deployment_any_capacity"` } +type GarnixConfig struct { + BaseUrl string `yaml:"baseUrl"` + CacheUrl string `yaml:"cacheUrl"` + RetryInterval int `yaml:"retry_interval"` + CacheSize int `yaml:"cache_size"` +} + +type ExecutorConfig struct { + Type string `yaml:"type"` + GarnixConfig GarnixConfig `yaml:"garnix"` +} + type Configuration struct { Hostname string `yaml:"hostname"` StateDir string `yaml:"state_dir"` StateFilepath string `yaml:"state_filepath"` // RepositoryType describes type of the repository. It can currently only be "flake" - RepositoryType string `yaml:"repository_type"` - RepositorySubdir string `yaml:"repository_subdir"` - Submodules bool `yaml:"submodules"` - SystemAttr string `yaml:"system_attr"` - Remotes []Remote `yaml:"remotes"` - ApiServer HttpServer `yaml:"api_server"` - Grpc Grpc `yaml:"grpc"` - Exporter HttpServer `yaml:"exporter"` - GpgPublicKeyPaths []string `yaml:"gpg_public_key_paths"` - PostDeploymentCommand string `yaml:"post_deployment_command"` - BuildConfirmer Confirmer `yaml:"build_confirmer"` - DeployConfirmer Confirmer `yaml:"deploy_confirmer"` - Retention Retention `yaml:"retention"` + RepositoryType string `yaml:"repository_type"` + RepositorySubdir string `yaml:"repository_subdir"` + Submodules bool `yaml:"submodules"` + SystemAttr string `yaml:"system_attr"` + Remotes []Remote `yaml:"remotes"` + ApiServer HttpServer `yaml:"api_server"` + Grpc Grpc `yaml:"grpc"` + Exporter HttpServer `yaml:"exporter"` + GpgPublicKeyPaths []string `yaml:"gpg_public_key_paths"` + PostDeploymentCommand string `yaml:"post_deployment_command"` + BuildConfirmer Confirmer `yaml:"build_confirmer"` + DeployConfirmer Confirmer `yaml:"deploy_confirmer"` + Retention Retention `yaml:"retention"` + ExecutorConfig ExecutorConfig `yaml:"executor"` } diff --git a/nix/comin-config.nix b/nix/comin-config.nix index 5df95585..499eba72 100644 --- a/nix/comin-config.nix +++ b/nix/comin-config.nix @@ -25,6 +25,7 @@ rec { build_confirmer = cfg.services.comin.buildConfirmer; deploy_confirmer = cfg.services.comin.deployConfirmer; retention = cfg.services.comin.retention; + executor = cfg.services.comin.executor; } // (lib.optionalAttrs (cfg.services.comin.postDeploymentCommand != null) { post_deployment_command = cfg.services.comin.postDeploymentCommand; diff --git a/nix/module-options.nix b/nix/module-options.nix index c41b4fea..8aa52539 100644 --- a/nix/module-options.nix +++ b/nix/module-options.nix @@ -27,6 +27,10 @@ in assertion = cfg.repositoryType == "flake" || cfg.repositoryType == "nix" && cfg.systemAttr != null; message = "When the `services.comin.repositoryType` is `nix`, the the configuration attribute `services.comin.systemAttr` must be set."; } + { + assertion = cfg.executor.type != "garnix" || cfg.repositoryType == "flake"; + message = "When `services.comin.executor.type` is `garnix`, `services.comin.repositoryType` must be `flake` (Garnix only supports flakes)."; + } ]; }) ]; @@ -381,6 +385,57 @@ in }; }; }; + executor = mkOption { + description = '' + Select which executor will be used for evaluating and building the system configuration. + + The `garnix` executor delegates evaluation and building to garnix.io and fetches + the result from its binary cache. For this to work, the user must add + `cache.garnix.io` to `nix.settings.substituters` and the corresponding + `cache.garnix.io-1:...` key to `nix.settings.trusted-public-keys`. + ''; + default = { }; + type = submodule { + options = { + type = mkOption { + type = enum [ + "nix" + "garnix" + ]; + default = "nix"; + description = "Type of executor to use (nix or garnix)."; + }; + garnix = mkOption { + description = "Configuration for the Garnix executor."; + default = { }; + type = submodule { + options = { + baseUrl = mkOption { + type = str; + default = ""; + description = "Base URL for the Garnix API. Defaults to https://garnix.io/ when empty."; + }; + cacheUrl = mkOption { + type = str; + default = ""; + description = "URL of the Garnix binary cache. Defaults to https://cache.garnix.io/ when empty."; + }; + retry_interval = mkOption { + type = int; + default = 0; + description = "Polling interval (in seconds) when waiting for a Garnix build. Defaults to 60 when 0."; + }; + cache_size = mkOption { + type = int; + default = 0; + description = "LRU cache size for drvPath -> outPath mappings. Defaults to 2 when 0."; + }; + }; + }; + }; + }; + }; + }; }; }; } From 0603417982f5d85efec3baa0123d595f428fb884 Mon Sep 17 00:00:00 2001 From: Xinyang Li Date: Fri, 8 May 2026 15:50:41 +0800 Subject: [PATCH 2/4] Add Hydra executor Adds a Hydra CI executor parallel to the existing Garnix executor: delegates evaluation/build to a Hydra instance and fetches the result from its binary cache. Flake-based jobsets only for now; enforced via both Go config validation and a NixOS module assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/run.go | 11 +- docs/generated-module-options.md | 156 +++++++++++++++++++- internal/config/config.go | 25 +++- internal/executor/hydra.go | 236 +++++++++++++++++++++++++++++++ internal/types/types.go | 10 ++ nix/module-options.nix | 50 ++++++- 6 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 internal/executor/hydra.go diff --git a/cmd/run.go b/cmd/run.go index 8ca3d4a4..39f2d192 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -54,13 +54,20 @@ var runCmd = &cobra.Command{ } var executor executorPkg.Executor - if cfg.ExecutorConfig.Type == "garnix" { + switch cfg.ExecutorConfig.Type { + case "garnix": systemAttr := "nixosConfigurations" if runtime.GOOS == "darwin" { systemAttr = "darwinConfigurations" } executor, err = executorPkg.NewGarnixExecutor(cfg.ExecutorConfig.GarnixConfig, systemAttr) - } else { + case "hydra": + systemAttr := "nixosConfigurations" + if runtime.GOOS == "darwin" { + systemAttr = "darwinConfigurations" + } + executor, err = executorPkg.NewHydraExecutor(cfg.ExecutorConfig.HydraConfig, systemAttr) + default: switch cfg.RepositoryType { case "flake": if runtime.GOOS == "darwin" { diff --git a/docs/generated-module-options.md b/docs/generated-module-options.md index ad3212a2..cc01b28d 100644 --- a/docs/generated-module-options.md +++ b/docs/generated-module-options.md @@ -263,6 +263,11 @@ the result from its binary cache\. For this to work, the user must add ` cache.garnix.io ` to ` nix.settings.substituters ` and the corresponding ` cache.garnix.io-1:... ` key to ` nix.settings.trusted-public-keys `\. +The ` hydra ` executor delegates evaluation and building to a Hydra CI instance and +fetches the result from its binary cache\. For this to work, the user must add the +corresponding cache URL to ` nix.settings.substituters ` and the matching public key +to ` nix.settings.trusted-public-keys `\. Only flake-based jobsets are supported\. + *Type:* @@ -383,16 +388,163 @@ signed integer +## services\.comin\.executor\.hydra + + + +Configuration for the Hydra executor\. + + + +*Type:* +submodule + + + +*Default:* + +```nix +{ } +``` + + + +## services\.comin\.executor\.hydra\.base_url + + + +Base URL of the Hydra instance, e\.g\. https://hydra\.example\.org\. + + + +*Type:* +string + + + +*Default:* + +```nix +"" +``` + + + +## services\.comin\.executor\.hydra\.job_name + + + +Job name to fetch from each evaluation\. Defaults to the hostname when empty\. + + + +*Type:* +string + + + +*Default:* + +```nix +"" +``` + + + +## services\.comin\.executor\.hydra\.jobset + + + +Hydra jobset name\. + + + +*Type:* +string + + + +*Default:* + +```nix +"" +``` + + + +## services\.comin\.executor\.hydra\.max_eval_pages + + + +Number of evaluation pages to scan per poll cycle\. Defaults to 5 when 0\. + + + +*Type:* +signed integer + + + +*Default:* + +```nix +0 +``` + + + +## services\.comin\.executor\.hydra\.project + + + +Hydra project name\. + + + +*Type:* +string + + + +*Default:* + +```nix +"" +``` + + + +## services\.comin\.executor\.hydra\.retry_interval + + + +Polling interval (in seconds) when waiting for a Hydra build\. Defaults to 60 when 0\. + + + +*Type:* +signed integer + + + +*Default:* + +```nix +0 +``` + + + ## services\.comin\.executor\.type -Type of executor to use (nix or garnix)\. +Type of executor to use (nix, garnix or hydra)\. *Type:* -one of “nix”, “garnix” +one of “nix”, “garnix”, “hydra” diff --git a/internal/config/config.go b/internal/config/config.go index 3884500a..57b7643e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,13 +72,36 @@ func Read(path string) (config types.Configuration, err error) { if config.ExecutorConfig.Type == "" { config.ExecutorConfig.Type = "nix" } - supportedExecutorTypes := []string{"nix", "garnix"} + supportedExecutorTypes := []string{"nix", "garnix", "hydra"} if !slices.Contains(supportedExecutorTypes, config.ExecutorConfig.Type) { return config, fmt.Errorf("config: executor type is '%s' while it must be one of '%s'", config.ExecutorConfig.Type, supportedExecutorTypes) } if config.ExecutorConfig.Type == "garnix" && config.RepositoryType != "flake" { return config, fmt.Errorf("config: executor type 'garnix' requires repository_type 'flake', got '%s'", config.RepositoryType) } + if config.ExecutorConfig.Type == "hydra" { + if config.RepositoryType != "flake" { + return config, fmt.Errorf("config: executor type 'hydra' requires repository_type 'flake', got '%s'", config.RepositoryType) + } + if config.ExecutorConfig.HydraConfig.BaseUrl == "" { + return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.base_url to be set") + } + if config.ExecutorConfig.HydraConfig.Project == "" { + return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.project to be set") + } + if config.ExecutorConfig.HydraConfig.Jobset == "" { + return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.jobset to be set") + } + if config.ExecutorConfig.HydraConfig.JobName == "" { + config.ExecutorConfig.HydraConfig.JobName = config.Hostname + } + if config.ExecutorConfig.HydraConfig.RetryInterval == 0 { + config.ExecutorConfig.HydraConfig.RetryInterval = 60 + } + if config.ExecutorConfig.HydraConfig.MaxEvalPages == 0 { + config.ExecutorConfig.HydraConfig.MaxEvalPages = 5 + } + } if config.Grpc.UnixSocketPath == "" { config.Grpc.UnixSocketPath = filepath.Join(config.StateDir, "grpc.sock") } diff --git a/internal/executor/hydra.go b/internal/executor/hydra.go new file mode 100644 index 00000000..1873efca --- /dev/null +++ b/internal/executor/hydra.go @@ -0,0 +1,236 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/nlewo/comin/internal/types" + "github.com/nlewo/comin/internal/utils" + "github.com/sirupsen/logrus" +) + +// Hydra evaluates and fetches the build result of a flake-based +// configuration from a Hydra CI instance, then pulls it from the +// Hydra binary cache (which the user must add to nix.settings.substituters). +type Hydra struct { + baseUrl url.URL + project string + jobset string + jobName string + retryInterval time.Duration + maxEvalPages int + + // systemAttr is "nixosConfigurations" or "darwinConfigurations" and + // drives platform-specific dispatch (deploy, reboot, machine-id). + systemAttr string + + // drv2Out maps drvPath -> outPath. The Executor interface only passes + // drvPath to Build, but fetchBuild needs outPath, so Eval stashes + // the mapping it just resolved. + mu sync.Mutex + drv2Out map[string]string +} + +func NewHydraExecutor(config types.HydraConfig, systemAttr string) (h *Hydra, err error) { + baseUrl, err := url.Parse(config.BaseUrl) + if err != nil { + return nil, err + } + + h = &Hydra{ + baseUrl: *baseUrl, + project: config.Project, + jobset: config.Jobset, + jobName: config.JobName, + retryInterval: time.Duration(config.RetryInterval) * time.Second, + maxEvalPages: config.MaxEvalPages, + systemAttr: systemAttr, + drv2Out: make(map[string]string), + } + return +} + +type hydraEval struct { + Id int `json:"id"` + Flake string `json:"flake"` + Builds []int `json:"builds"` +} + +type hydraEvalsPage struct { + First string `json:"first"` + Next string `json:"next"` + Evals []hydraEval `json:"evals"` +} + +type hydraBuildOutput struct { + Path string `json:"path"` +} + +type hydraBuild struct { + Id int `json:"id"` + Job string `json:"job"` + System string `json:"system"` + Finished int `json:"finished"` + BuildStatus *int `json:"buildstatus"` + DrvPath string `json:"drvpath"` + BuildOutputs map[string]hydraBuildOutput `json:"buildoutputs"` +} + +// extractRevFromFlakeUrl extracts the rev from a flake URL like +// "github:owner/repo/?narHash=..." → "". +func extractRevFromFlakeUrl(flake string) string { + if i := strings.Index(flake, "?"); i >= 0 { + flake = flake[:i] + } + if i := strings.LastIndex(flake, "/"); i >= 0 { + return flake[i+1:] + } + return flake +} + +func (h *Hydra) getJSON(ctx context.Context, u string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + logrus.Warnf("hydra: failed to close response body: %v", cerr) + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("hydra: GET %s returned status %d", u, resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// Eval polls the Hydra API for a build matching the given commit and +// jobName, blocking until the build has finished successfully. The returned +// machineId is always empty: deriving the expected machine-id would require +// a local flake evaluation, which defeats the purpose of the Hydra executor. +func (h *Hydra) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { + machineId = "" + if commitId == "" { + err = errors.New("hydra: commitId is required") + return + } + + for { + matchedButNotFinished := false + + for page := 1; page <= h.maxEvalPages; page++ { + pageUrl := h.baseUrl.JoinPath("/jobset/", h.project, h.jobset, "evals") + q := pageUrl.Query() + q.Set("page", fmt.Sprintf("%d", page)) + pageUrl.RawQuery = q.Encode() + logrus.Infof("hydra: fetching evaluations from %s", pageUrl) + + var evals hydraEvalsPage + if err = h.getJSON(ctx, pageUrl.String(), &evals); err != nil { + return + } + + for _, ev := range evals.Evals { + rev := extractRevFromFlakeUrl(ev.Flake) + if rev == "" || !strings.HasPrefix(rev, commitId) { + continue + } + for _, buildId := range ev.Builds { + buildUrl := h.baseUrl.JoinPath("/build/", fmt.Sprintf("%d", buildId)) + var b hydraBuild + if err = h.getJSON(ctx, buildUrl.String(), &b); err != nil { + return + } + if b.Job != h.jobName { + continue + } + if b.Finished == 0 { + logrus.Infof("hydra: matched build %d for %s/%s but not finished yet, retrying...", b.Id, commitId, h.jobName) + matchedButNotFinished = true + break + } + if b.BuildStatus == nil || *b.BuildStatus != 0 { + err = fmt.Errorf("hydra: build %d for %s/%s failed (buildstatus=%v)", b.Id, commitId, h.jobName, b.BuildStatus) + return + } + out, ok := b.BuildOutputs["out"] + if !ok || out.Path == "" { + err = fmt.Errorf("hydra: build %d for %s/%s has no 'out' output path", b.Id, commitId, h.jobName) + return + } + drvPath = b.DrvPath + outPath = out.Path + logrus.Infof("hydra: matched build %d for %s/%s drv=%s out=%s", b.Id, commitId, h.jobName, drvPath, outPath) + h.mu.Lock() + h.drv2Out[drvPath] = outPath + h.mu.Unlock() + return + } + if matchedButNotFinished { + break + } + } + if matchedButNotFinished { + break + } + if evals.Next == "" { + break + } + } + + select { + case <-time.After(h.retryInterval): + case <-ctx.Done(): + err = ctx.Err() + return + } + } +} + +func (h *Hydra) Build(ctx context.Context, drvPath string) (err error) { + logrus.Infof("hydra: fetching build for %s", drvPath) + h.mu.Lock() + outPath, ok := h.drv2Out[drvPath] + h.mu.Unlock() + if !ok { + return errors.New("hydra: build called before eval") + } + return fetchBuild(ctx, outPath) +} + +func (h *Hydra) Deploy(ctx context.Context, outPath, operation string, profilePaths []string) (needToRestartComin bool, profilePath string, err error) { + return deploy(ctx, outPath, operation, h.systemAttr, profilePaths) +} + +func (h *Hydra) IsStorePathExist(storePath string) bool { + return isStorePathExist(storePath) +} + +func (h *Hydra) NeedToReboot(outPath, operation string) bool { + if h.systemAttr == "darwinConfigurations" { + // See NixFlakeLocal.NeedToReboot: Darwin lacks the + // /run/current-system vs /run/booted-system mechanism, so + // conservatively assume no reboot is needed. + return false + } + return utils.NeedToRebootLinux(outPath, operation) +} + +func (h *Hydra) ReadMachineId() (machineId string, err error) { + if h.systemAttr == "darwinConfigurations" { + return utils.ReadMachineIdDarwin() + } + return utils.ReadMachineIdLinux() +} diff --git a/internal/types/types.go b/internal/types/types.go index bb4d0448..287dbb6d 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -69,9 +69,19 @@ type GarnixConfig struct { CacheSize int `yaml:"cache_size"` } +type HydraConfig struct { + BaseUrl string `yaml:"base_url"` + Project string `yaml:"project"` + Jobset string `yaml:"jobset"` + JobName string `yaml:"job_name"` + RetryInterval int `yaml:"retry_interval"` + MaxEvalPages int `yaml:"max_eval_pages"` +} + type ExecutorConfig struct { Type string `yaml:"type"` GarnixConfig GarnixConfig `yaml:"garnix"` + HydraConfig HydraConfig `yaml:"hydra"` } type Configuration struct { diff --git a/nix/module-options.nix b/nix/module-options.nix index 8aa52539..d49f0ad0 100644 --- a/nix/module-options.nix +++ b/nix/module-options.nix @@ -31,6 +31,10 @@ in assertion = cfg.executor.type != "garnix" || cfg.repositoryType == "flake"; message = "When `services.comin.executor.type` is `garnix`, `services.comin.repositoryType` must be `flake` (Garnix only supports flakes)."; } + { + assertion = cfg.executor.type != "hydra" || cfg.repositoryType == "flake"; + message = "When `services.comin.executor.type` is `hydra`, `services.comin.repositoryType` must be `flake` (the Hydra executor currently only supports flake-based jobsets)."; + } ]; }) ]; @@ -393,6 +397,11 @@ in the result from its binary cache. For this to work, the user must add `cache.garnix.io` to `nix.settings.substituters` and the corresponding `cache.garnix.io-1:...` key to `nix.settings.trusted-public-keys`. + + The `hydra` executor delegates evaluation and building to a Hydra CI instance and + fetches the result from its binary cache. For this to work, the user must add the + corresponding cache URL to `nix.settings.substituters` and the matching public key + to `nix.settings.trusted-public-keys`. Only flake-based jobsets are supported. ''; default = { }; type = submodule { @@ -401,9 +410,10 @@ in type = enum [ "nix" "garnix" + "hydra" ]; default = "nix"; - description = "Type of executor to use (nix or garnix)."; + description = "Type of executor to use (nix, garnix or hydra)."; }; garnix = mkOption { description = "Configuration for the Garnix executor."; @@ -433,6 +443,44 @@ in }; }; }; + hydra = mkOption { + description = "Configuration for the Hydra executor."; + default = { }; + type = submodule { + options = { + base_url = mkOption { + type = str; + default = ""; + description = "Base URL of the Hydra instance, e.g. https://hydra.example.org."; + }; + project = mkOption { + type = str; + default = ""; + description = "Hydra project name."; + }; + jobset = mkOption { + type = str; + default = ""; + description = "Hydra jobset name."; + }; + job_name = mkOption { + type = str; + default = ""; + description = "Job name to fetch from each evaluation. Defaults to the hostname when empty."; + }; + retry_interval = mkOption { + type = int; + default = 0; + description = "Polling interval (in seconds) when waiting for a Hydra build. Defaults to 60 when 0."; + }; + max_eval_pages = mkOption { + type = int; + default = 0; + description = "Number of evaluation pages to scan per poll cycle. Defaults to 5 when 0."; + }; + }; + }; + }; }; }; }; From 8284f97522cc55de560a37b0fbdc75f56854b550 Mon Sep 17 00:00:00 2001 From: Xinyang Li Date: Fri, 8 May 2026 18:31:45 +0800 Subject: [PATCH 3/4] hydra: support multiple jobsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `executor.hydra.jobset` (string) with `jobsets` (list of strings). Each tick the executor walks the configured jobsets in order and returns the first finished-success build of the current commit. This handles the common case where production and testing branches are evaluated by different Hydra jobsets — the user lists both, and whichever jobset built the commit is the one comin uses. A finished failure in any jobset is treated as authoritative and short- circuits without falling through; only "no match" or "still building" permits scanning subsequent jobsets. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/generated-module-options.md | 13 ++- internal/config/config.go | 9 +- internal/executor/hydra.go | 144 ++++++++++++++++++------------- internal/types/types.go | 12 +-- nix/module-options.nix | 15 +++- 5 files changed, 117 insertions(+), 76 deletions(-) diff --git a/docs/generated-module-options.md b/docs/generated-module-options.md index cc01b28d..01038d88 100644 --- a/docs/generated-module-options.md +++ b/docs/generated-module-options.md @@ -451,23 +451,28 @@ string -## services\.comin\.executor\.hydra\.jobset +## services\.comin\.executor\.hydra\.jobsets -Hydra jobset name\. +List of Hydra jobset names to scan\. Each tick the +executor walks every jobset in order and returns the +first finished-success build of the current commit\. +Use multiple jobsets when production and testing +branches are evaluated by different jobsets (e\.g\. +` [ "nixos-config-deploy" "nixos-config-deploy-testing" ] `)\. *Type:* -string +list of string *Default:* ```nix -"" +[ ] ``` diff --git a/internal/config/config.go b/internal/config/config.go index 57b7643e..4cf8b957 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,8 +89,13 @@ func Read(path string) (config types.Configuration, err error) { if config.ExecutorConfig.HydraConfig.Project == "" { return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.project to be set") } - if config.ExecutorConfig.HydraConfig.Jobset == "" { - return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.jobset to be set") + if len(config.ExecutorConfig.HydraConfig.Jobsets) == 0 { + return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.jobsets to contain at least one jobset") + } + for i, j := range config.ExecutorConfig.HydraConfig.Jobsets { + if j == "" { + return config, fmt.Errorf("config: executor.hydra.jobsets[%d] is empty", i) + } } if config.ExecutorConfig.HydraConfig.JobName == "" { config.ExecutorConfig.HydraConfig.JobName = config.Hostname diff --git a/internal/executor/hydra.go b/internal/executor/hydra.go index 1873efca..963cc828 100644 --- a/internal/executor/hydra.go +++ b/internal/executor/hydra.go @@ -22,7 +22,7 @@ import ( type Hydra struct { baseUrl url.URL project string - jobset string + jobsets []string jobName string retryInterval time.Duration maxEvalPages int @@ -47,7 +47,7 @@ func NewHydraExecutor(config types.HydraConfig, systemAttr string) (h *Hydra, er h = &Hydra{ baseUrl: *baseUrl, project: config.Project, - jobset: config.Jobset, + jobsets: config.Jobsets, jobName: config.JobName, retryInterval: time.Duration(config.RetryInterval) * time.Second, maxEvalPages: config.MaxEvalPages, @@ -116,8 +116,78 @@ func (h *Hydra) getJSON(ctx context.Context, u string, out any) error { return json.NewDecoder(resp.Body).Decode(out) } +// scanResult is the outcome of scanning a single jobset for a commit. +type scanResult int + +const ( + scanNoMatch scanResult = iota // commit/job not found in this jobset's recent evals + scanPending // commit+job matched but the build hasn't finished yet + scanSuccess // commit+job matched and the build succeeded +) + +// scanJobset walks max_eval_pages of evaluations for a single jobset and +// looks for the first build whose eval matches commitId and whose Build.job +// matches h.jobName. Returns scanSuccess with drv/out on a finished-success +// match, scanPending if the match is still building, scanNoMatch otherwise. +func (h *Hydra) scanJobset(ctx context.Context, jobset, commitId string) (drvPath, outPath string, result scanResult, err error) { + for page := 1; page <= h.maxEvalPages; page++ { + pageUrl := h.baseUrl.JoinPath("/jobset/", h.project, jobset, "evals") + q := pageUrl.Query() + q.Set("page", fmt.Sprintf("%d", page)) + pageUrl.RawQuery = q.Encode() + logrus.Infof("hydra: fetching evaluations from %s", pageUrl) + + var evals hydraEvalsPage + if err = h.getJSON(ctx, pageUrl.String(), &evals); err != nil { + return + } + + for _, ev := range evals.Evals { + rev := extractRevFromFlakeUrl(ev.Flake) + if rev == "" || !strings.HasPrefix(rev, commitId) { + continue + } + for _, buildId := range ev.Builds { + buildUrl := h.baseUrl.JoinPath("/build/", fmt.Sprintf("%d", buildId)) + var b hydraBuild + if err = h.getJSON(ctx, buildUrl.String(), &b); err != nil { + return + } + if b.Job != h.jobName { + continue + } + if b.Finished == 0 { + logrus.Infof("hydra: jobset=%s matched build %d for %s/%s but not finished yet", jobset, b.Id, commitId, h.jobName) + result = scanPending + return + } + if b.BuildStatus == nil || *b.BuildStatus != 0 { + err = fmt.Errorf("hydra: jobset=%s build %d for %s/%s failed (buildstatus=%v)", jobset, b.Id, commitId, h.jobName, b.BuildStatus) + return + } + out, ok := b.BuildOutputs["out"] + if !ok || out.Path == "" { + err = fmt.Errorf("hydra: jobset=%s build %d for %s/%s has no 'out' output path", jobset, b.Id, commitId, h.jobName) + return + } + drvPath = b.DrvPath + outPath = out.Path + logrus.Infof("hydra: jobset=%s matched build %d for %s/%s drv=%s out=%s", jobset, b.Id, commitId, h.jobName, drvPath, outPath) + result = scanSuccess + return + } + } + if evals.Next == "" { + break + } + } + return "", "", scanNoMatch, nil +} + // Eval polls the Hydra API for a build matching the given commit and -// jobName, blocking until the build has finished successfully. The returned +// jobName, blocking until the build has finished successfully. Each tick +// scans every configured jobset in order; the first jobset whose recent +// evals contain a finished-success build for this commit wins. The returned // machineId is always empty: deriving the expected machine-id would require // a local flake evaluation, which defeats the purpose of the Hydra executor. func (h *Hydra) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { @@ -128,66 +198,20 @@ func (h *Hydra) Eval(ctx context.Context, repositoryPath, repositorySubdir, comm } for { - matchedButNotFinished := false - - for page := 1; page <= h.maxEvalPages; page++ { - pageUrl := h.baseUrl.JoinPath("/jobset/", h.project, h.jobset, "evals") - q := pageUrl.Query() - q.Set("page", fmt.Sprintf("%d", page)) - pageUrl.RawQuery = q.Encode() - logrus.Infof("hydra: fetching evaluations from %s", pageUrl) - - var evals hydraEvalsPage - if err = h.getJSON(ctx, pageUrl.String(), &evals); err != nil { + for _, jobset := range h.jobsets { + var result scanResult + drvPath, outPath, result, err = h.scanJobset(ctx, jobset, commitId) + if err != nil { return } - - for _, ev := range evals.Evals { - rev := extractRevFromFlakeUrl(ev.Flake) - if rev == "" || !strings.HasPrefix(rev, commitId) { - continue - } - for _, buildId := range ev.Builds { - buildUrl := h.baseUrl.JoinPath("/build/", fmt.Sprintf("%d", buildId)) - var b hydraBuild - if err = h.getJSON(ctx, buildUrl.String(), &b); err != nil { - return - } - if b.Job != h.jobName { - continue - } - if b.Finished == 0 { - logrus.Infof("hydra: matched build %d for %s/%s but not finished yet, retrying...", b.Id, commitId, h.jobName) - matchedButNotFinished = true - break - } - if b.BuildStatus == nil || *b.BuildStatus != 0 { - err = fmt.Errorf("hydra: build %d for %s/%s failed (buildstatus=%v)", b.Id, commitId, h.jobName, b.BuildStatus) - return - } - out, ok := b.BuildOutputs["out"] - if !ok || out.Path == "" { - err = fmt.Errorf("hydra: build %d for %s/%s has no 'out' output path", b.Id, commitId, h.jobName) - return - } - drvPath = b.DrvPath - outPath = out.Path - logrus.Infof("hydra: matched build %d for %s/%s drv=%s out=%s", b.Id, commitId, h.jobName, drvPath, outPath) - h.mu.Lock() - h.drv2Out[drvPath] = outPath - h.mu.Unlock() - return - } - if matchedButNotFinished { - break - } - } - if matchedButNotFinished { - break - } - if evals.Next == "" { - break + if result == scanSuccess { + h.mu.Lock() + h.drv2Out[drvPath] = outPath + h.mu.Unlock() + return } + // scanPending or scanNoMatch: keep scanning remaining jobsets, + // then sleep and retry from the start. } select { diff --git a/internal/types/types.go b/internal/types/types.go index 287dbb6d..47cc90e3 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -70,12 +70,12 @@ type GarnixConfig struct { } type HydraConfig struct { - BaseUrl string `yaml:"base_url"` - Project string `yaml:"project"` - Jobset string `yaml:"jobset"` - JobName string `yaml:"job_name"` - RetryInterval int `yaml:"retry_interval"` - MaxEvalPages int `yaml:"max_eval_pages"` + BaseUrl string `yaml:"base_url"` + Project string `yaml:"project"` + Jobsets []string `yaml:"jobsets"` + JobName string `yaml:"job_name"` + RetryInterval int `yaml:"retry_interval"` + MaxEvalPages int `yaml:"max_eval_pages"` } type ExecutorConfig struct { diff --git a/nix/module-options.nix b/nix/module-options.nix index d49f0ad0..22ff7f42 100644 --- a/nix/module-options.nix +++ b/nix/module-options.nix @@ -458,10 +458,17 @@ in default = ""; description = "Hydra project name."; }; - jobset = mkOption { - type = str; - default = ""; - description = "Hydra jobset name."; + jobsets = mkOption { + type = listOf str; + default = [ ]; + description = '' + List of Hydra jobset names to scan. Each tick the + executor walks every jobset in order and returns the + first finished-success build of the current commit. + Use multiple jobsets when production and testing + branches are evaluated by different jobsets (e.g. + `[ "nixos-config-deploy" "nixos-config-deploy-testing" ]`). + ''; }; job_name = mkOption { type = str; From b53cfe885f26f47242562888b36853ae9a324498 Mon Sep 17 00:00:00 2001 From: Xinyang Li Date: Wed, 3 Jun 2026 13:09:16 +0800 Subject: [PATCH 4/4] hydra: derive jobset from branch name with optional prefix Replace the `executor.hydra.jobsets` (list of strings) added in the previous commit with a single jobset derived at eval time from the branch being deployed: `jobset_prefix` is prepended to the selected branch name (so branch "main" with prefix "nixos-" scans jobset "nixos-main"). This matches the common Hydra convention of naming jobsets after branches, and avoids making users enumerate jobsets by hand. To do this, thread the selected branch name through the Executor.Eval interface (it was available on the Generation but not forwarded). The nix, nix-flake and garnix executors accept and ignore the new argument. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/generated-module-options.md | 17 ++-- internal/builder/builder.go | 20 ++-- internal/builder/builder_test.go | 2 +- internal/config/config.go | 8 -- internal/executor/executor.go | 4 +- internal/executor/executor_test.go | 2 +- internal/executor/garnix.go | 2 +- internal/executor/hydra.go | 156 +++++++++++++---------------- internal/executor/nix.go | 2 +- internal/executor/nix_flake.go | 2 +- internal/manager/manager_test.go | 2 +- internal/types/types.go | 12 +-- nix/module-options.nix | 17 ++-- 13 files changed, 111 insertions(+), 135 deletions(-) diff --git a/docs/generated-module-options.md b/docs/generated-module-options.md index 01038d88..a31b3fe8 100644 --- a/docs/generated-module-options.md +++ b/docs/generated-module-options.md @@ -451,28 +451,27 @@ string -## services\.comin\.executor\.hydra\.jobsets +## services\.comin\.executor\.hydra\.jobset_prefix -List of Hydra jobset names to scan\. Each tick the -executor walks every jobset in order and returns the -first finished-success build of the current commit\. -Use multiple jobsets when production and testing -branches are evaluated by different jobsets (e\.g\. -` [ "nixos-config-deploy" "nixos-config-deploy-testing" ] `)\. +Optional prefix prepended to the deployed branch name +to form the Hydra jobset name to scan\. For example, +prefix ` nixos- ` with branch ` main ` scans jobset +` nixos-main `\. When empty, the jobset name equals the +branch name\. *Type:* -list of string +string *Default:* ```nix -[ ] +"" ``` diff --git a/internal/builder/builder.go b/internal/builder/builder.go index ad94864c..bd276dbb 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -143,12 +143,13 @@ func (b *Builder) Stop() { } type Evaluator struct { - repositoryPath string - repostorySubdir string - systemAttr string - commitId string - hostname string - submodules bool + repositoryPath string + repostorySubdir string + systemAttr string + commitId string + selectedBranchName string + hostname string + submodules bool evalFunc executor.EvalFunc @@ -158,7 +159,7 @@ type Evaluator struct { } func (r *Evaluator) Run(ctx context.Context) (err error) { - r.drvPath, r.outPath, r.machineId, err = r.evalFunc(ctx, r.repositoryPath, r.repostorySubdir, r.commitId, r.systemAttr, r.hostname, r.submodules) + r.drvPath, r.outPath, r.machineId, err = r.evalFunc(ctx, r.repositoryPath, r.repostorySubdir, r.commitId, r.selectedBranchName, r.systemAttr, r.hostname, r.submodules) return err } @@ -199,8 +200,9 @@ func (b *Builder) Eval(ctx context.Context, rs *protobuf.RepositoryStatus) error systemAttr: g.SystemAttr, submodules: b.submodules, - commitId: g.SelectedCommitId, - evalFunc: b.executor.Eval, + commitId: g.SelectedCommitId, + selectedBranchName: g.SelectedBranchName, + evalFunc: b.executor.Eval, } b.evaluator = NewExec(evaluator, b.evalTimeout) diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index ed1ec6dd..858e5f81 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -33,7 +33,7 @@ func (n ExecutorMock) IsStorePathExist(storePath string) bool { func (n ExecutorMock) Deploy(ctx context.Context, outPath, operation string, profilePaths []string) (needToRestartComin bool, profilePath string, err error) { return false, "", nil } -func (n ExecutorMock) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { +func (n ExecutorMock) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { select { case <-ctx.Done(): return "", "", "", ctx.Err() diff --git a/internal/config/config.go b/internal/config/config.go index 4cf8b957..23e2792c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,14 +89,6 @@ func Read(path string) (config types.Configuration, err error) { if config.ExecutorConfig.HydraConfig.Project == "" { return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.project to be set") } - if len(config.ExecutorConfig.HydraConfig.Jobsets) == 0 { - return config, fmt.Errorf("config: executor type 'hydra' requires executor.hydra.jobsets to contain at least one jobset") - } - for i, j := range config.ExecutorConfig.HydraConfig.Jobsets { - if j == "" { - return config, fmt.Errorf("config: executor.hydra.jobsets[%d] is empty", i) - } - } if config.ExecutorConfig.HydraConfig.JobName == "" { config.ExecutorConfig.HydraConfig.JobName = config.Hostname } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 610d1414..81663a99 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -6,7 +6,7 @@ import ( "github.com/sirupsen/logrus" ) -type EvalFunc func(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) +type EvalFunc func(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) type BuildFunc func(ctx context.Context, drvPath string) error // Executor contains the function used by comin to actually do actions @@ -15,7 +15,7 @@ type BuildFunc func(ctx context.Context, drvPath string) error // Garnix implementation (such as proposed in // https://github.com/nlewo/comin/pull/74) type Executor interface { - Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) + Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) Build(ctx context.Context, drvPath string) (err error) Deploy(ctx context.Context, outPath, operation string, profilePaths []string) (needToRestartComin bool, profilePath string, err error) NeedToReboot(outPath, operation string) bool diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index a6f9ec12..d99f3b21 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -60,7 +60,7 @@ func TestNixExecutorEval(t *testing.T) { // Test that Eval doesn't panic and handles parameters correctly // This will error in test environment since nix commands will fail, // but we're testing the code path and parameter handling - _, _, _, err = executor.Eval(ctx, tt.repositoryPath, tt.repositorySubdir, tt.commitId, tt.systemAttr, tt.hostname, false) + _, _, _, err = executor.Eval(ctx, tt.repositoryPath, tt.repositorySubdir, tt.commitId, "", tt.systemAttr, tt.hostname, false) t.Logf("Eval with %s returned error: %v (expected in test environment)", tt.systemAttr, err) }) } diff --git a/internal/executor/garnix.go b/internal/executor/garnix.go index 2097d4ea..9fdb7c50 100644 --- a/internal/executor/garnix.go +++ b/internal/executor/garnix.go @@ -102,7 +102,7 @@ func (g *Garnix) expectedPackageType() string { // cache. The returned machineId is always empty: deriving the expected // machine-id would require a local flake evaluation, which defeats the // purpose of the Garnix executor. -func (g *Garnix) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { +func (g *Garnix) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { machineId = "" if commitId == "" { err = errors.New("garnix: commitId is required") diff --git a/internal/executor/hydra.go b/internal/executor/hydra.go index 963cc828..1ca72f4e 100644 --- a/internal/executor/hydra.go +++ b/internal/executor/hydra.go @@ -22,7 +22,7 @@ import ( type Hydra struct { baseUrl url.URL project string - jobsets []string + jobsetPrefix string jobName string retryInterval time.Duration maxEvalPages int @@ -47,7 +47,7 @@ func NewHydraExecutor(config types.HydraConfig, systemAttr string) (h *Hydra, er h = &Hydra{ baseUrl: *baseUrl, project: config.Project, - jobsets: config.Jobsets, + jobsetPrefix: config.JobsetPrefix, jobName: config.JobName, retryInterval: time.Duration(config.RetryInterval) * time.Second, maxEvalPages: config.MaxEvalPages, @@ -116,102 +116,86 @@ func (h *Hydra) getJSON(ctx context.Context, u string, out any) error { return json.NewDecoder(resp.Body).Decode(out) } -// scanResult is the outcome of scanning a single jobset for a commit. -type scanResult int - -const ( - scanNoMatch scanResult = iota // commit/job not found in this jobset's recent evals - scanPending // commit+job matched but the build hasn't finished yet - scanSuccess // commit+job matched and the build succeeded -) +// Eval polls the Hydra API for a build matching the given commit and +// jobName, blocking until the build has finished successfully. The jobset +// scanned is derived from the branch being deployed: jobset_prefix is +// prepended to selectedBranchName (so branch "main" with prefix "nixos-" +// scans jobset "nixos-main"). The returned machineId is always empty: +// deriving the expected machine-id would require a local flake evaluation, +// which defeats the purpose of the Hydra executor. +func (h *Hydra) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { + machineId = "" + if commitId == "" { + err = errors.New("hydra: commitId is required") + return + } + if selectedBranchName == "" { + err = errors.New("hydra: selectedBranchName is required to derive the jobset name") + return + } + jobset := h.jobsetPrefix + selectedBranchName -// scanJobset walks max_eval_pages of evaluations for a single jobset and -// looks for the first build whose eval matches commitId and whose Build.job -// matches h.jobName. Returns scanSuccess with drv/out on a finished-success -// match, scanPending if the match is still building, scanNoMatch otherwise. -func (h *Hydra) scanJobset(ctx context.Context, jobset, commitId string) (drvPath, outPath string, result scanResult, err error) { - for page := 1; page <= h.maxEvalPages; page++ { - pageUrl := h.baseUrl.JoinPath("/jobset/", h.project, jobset, "evals") - q := pageUrl.Query() - q.Set("page", fmt.Sprintf("%d", page)) - pageUrl.RawQuery = q.Encode() - logrus.Infof("hydra: fetching evaluations from %s", pageUrl) + for { + matchedButNotFinished := false - var evals hydraEvalsPage - if err = h.getJSON(ctx, pageUrl.String(), &evals); err != nil { - return - } + for page := 1; page <= h.maxEvalPages; page++ { + pageUrl := h.baseUrl.JoinPath("/jobset/", h.project, jobset, "evals") + q := pageUrl.Query() + q.Set("page", fmt.Sprintf("%d", page)) + pageUrl.RawQuery = q.Encode() + logrus.Infof("hydra: fetching evaluations from %s", pageUrl) - for _, ev := range evals.Evals { - rev := extractRevFromFlakeUrl(ev.Flake) - if rev == "" || !strings.HasPrefix(rev, commitId) { - continue + var evals hydraEvalsPage + if err = h.getJSON(ctx, pageUrl.String(), &evals); err != nil { + return } - for _, buildId := range ev.Builds { - buildUrl := h.baseUrl.JoinPath("/build/", fmt.Sprintf("%d", buildId)) - var b hydraBuild - if err = h.getJSON(ctx, buildUrl.String(), &b); err != nil { - return - } - if b.Job != h.jobName { + + for _, ev := range evals.Evals { + rev := extractRevFromFlakeUrl(ev.Flake) + if rev == "" || !strings.HasPrefix(rev, commitId) { continue } - if b.Finished == 0 { - logrus.Infof("hydra: jobset=%s matched build %d for %s/%s but not finished yet", jobset, b.Id, commitId, h.jobName) - result = scanPending - return - } - if b.BuildStatus == nil || *b.BuildStatus != 0 { - err = fmt.Errorf("hydra: jobset=%s build %d for %s/%s failed (buildstatus=%v)", jobset, b.Id, commitId, h.jobName, b.BuildStatus) + for _, buildId := range ev.Builds { + buildUrl := h.baseUrl.JoinPath("/build/", fmt.Sprintf("%d", buildId)) + var b hydraBuild + if err = h.getJSON(ctx, buildUrl.String(), &b); err != nil { + return + } + if b.Job != h.jobName { + continue + } + if b.Finished == 0 { + logrus.Infof("hydra: matched build %d for %s/%s but not finished yet, retrying...", b.Id, commitId, h.jobName) + matchedButNotFinished = true + break + } + if b.BuildStatus == nil || *b.BuildStatus != 0 { + err = fmt.Errorf("hydra: build %d for %s/%s failed (buildstatus=%v)", b.Id, commitId, h.jobName, b.BuildStatus) + return + } + out, ok := b.BuildOutputs["out"] + if !ok || out.Path == "" { + err = fmt.Errorf("hydra: build %d for %s/%s has no 'out' output path", b.Id, commitId, h.jobName) + return + } + drvPath = b.DrvPath + outPath = out.Path + logrus.Infof("hydra: matched build %d for %s/%s drv=%s out=%s", b.Id, commitId, h.jobName, drvPath, outPath) + h.mu.Lock() + h.drv2Out[drvPath] = outPath + h.mu.Unlock() return } - out, ok := b.BuildOutputs["out"] - if !ok || out.Path == "" { - err = fmt.Errorf("hydra: jobset=%s build %d for %s/%s has no 'out' output path", jobset, b.Id, commitId, h.jobName) - return + if matchedButNotFinished { + break } - drvPath = b.DrvPath - outPath = out.Path - logrus.Infof("hydra: jobset=%s matched build %d for %s/%s drv=%s out=%s", jobset, b.Id, commitId, h.jobName, drvPath, outPath) - result = scanSuccess - return } - } - if evals.Next == "" { - break - } - } - return "", "", scanNoMatch, nil -} - -// Eval polls the Hydra API for a build matching the given commit and -// jobName, blocking until the build has finished successfully. Each tick -// scans every configured jobset in order; the first jobset whose recent -// evals contain a finished-success build for this commit wins. The returned -// machineId is always empty: deriving the expected machine-id would require -// a local flake evaluation, which defeats the purpose of the Hydra executor. -func (h *Hydra) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { - machineId = "" - if commitId == "" { - err = errors.New("hydra: commitId is required") - return - } - - for { - for _, jobset := range h.jobsets { - var result scanResult - drvPath, outPath, result, err = h.scanJobset(ctx, jobset, commitId) - if err != nil { - return + if matchedButNotFinished { + break } - if result == scanSuccess { - h.mu.Lock() - h.drv2Out[drvPath] = outPath - h.mu.Unlock() - return + if evals.Next == "" { + break } - // scanPending or scanNoMatch: keep scanning remaining jobsets, - // then sleep and retry from the start. } select { diff --git a/internal/executor/nix.go b/internal/executor/nix.go index 7be94980..051db08f 100644 --- a/internal/executor/nix.go +++ b/internal/executor/nix.go @@ -30,7 +30,7 @@ func (n *NixLocal) NeedToReboot(outPath, operation string) bool { return utils.NeedToRebootLinux(outPath, operation) } -func (n *NixLocal) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { +func (n *NixLocal) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { tempDir, err := cloneRepoToTemp(repositoryPath, commitId, submodules) defer os.RemoveAll(tempDir) // nolint: errcheck if err != nil { diff --git a/internal/executor/nix_flake.go b/internal/executor/nix_flake.go index e903ef9d..543691d2 100644 --- a/internal/executor/nix_flake.go +++ b/internal/executor/nix_flake.go @@ -45,7 +45,7 @@ func (n *NixFlakeLocal) ShowDerivation(ctx context.Context, flakeUrl, hostname s return showDerivationWithFlake(ctx, flakeUrl, hostname, n.systemAttr) } -func (n *NixFlakeLocal) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { +func (n *NixFlakeLocal) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { flakeUrl := fmt.Sprintf("git+file://%s?dir=%s&rev=%s", repositoryPath, repositorySubdir, commitId) if submodules { flakeUrl += "&submodules=1" diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go index d6a81c19..77f431a4 100644 --- a/internal/manager/manager_test.go +++ b/internal/manager/manager_test.go @@ -53,7 +53,7 @@ func (n ExecutorMock) IsStorePathExist(storePath string) bool { func (n ExecutorMock) Deploy(ctx context.Context, outPath, operation string, profilePaths []string) (needToRestartComin bool, profilePath string, err error) { return false, "", nil } -func (n ExecutorMock) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { +func (n ExecutorMock) Eval(ctx context.Context, repositoryPath, repositorySubdir, commitId, selectedBranchName, systemAttr, hostname string, submodules bool) (drvPath string, outPath string, machineId string, err error) { ok := <-n.evalOk if ok { return "drv-path", "out-path", n.machineId, nil diff --git a/internal/types/types.go b/internal/types/types.go index 47cc90e3..36128490 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -70,12 +70,12 @@ type GarnixConfig struct { } type HydraConfig struct { - BaseUrl string `yaml:"base_url"` - Project string `yaml:"project"` - Jobsets []string `yaml:"jobsets"` - JobName string `yaml:"job_name"` - RetryInterval int `yaml:"retry_interval"` - MaxEvalPages int `yaml:"max_eval_pages"` + BaseUrl string `yaml:"base_url"` + Project string `yaml:"project"` + JobsetPrefix string `yaml:"jobset_prefix"` + JobName string `yaml:"job_name"` + RetryInterval int `yaml:"retry_interval"` + MaxEvalPages int `yaml:"max_eval_pages"` } type ExecutorConfig struct { diff --git a/nix/module-options.nix b/nix/module-options.nix index 22ff7f42..bc2f3f25 100644 --- a/nix/module-options.nix +++ b/nix/module-options.nix @@ -458,16 +458,15 @@ in default = ""; description = "Hydra project name."; }; - jobsets = mkOption { - type = listOf str; - default = [ ]; + jobset_prefix = mkOption { + type = str; + default = ""; description = '' - List of Hydra jobset names to scan. Each tick the - executor walks every jobset in order and returns the - first finished-success build of the current commit. - Use multiple jobsets when production and testing - branches are evaluated by different jobsets (e.g. - `[ "nixos-config-deploy" "nixos-config-deploy-testing" ]`). + Optional prefix prepended to the deployed branch name + to form the Hydra jobset name to scan. For example, + prefix `nixos-` with branch `main` scans jobset + `nixos-main`. When empty, the jobset name equals the + branch name. ''; }; job_name = mkOption {