diff --git a/cmd/run.go b/cmd/run.go index c354b07..39f2d19 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -54,14 +54,30 @@ var runCmd = &cobra.Command{ } var executor executorPkg.Executor - switch cfg.RepositoryType { - case "flake": - executor, err = executorPkg.NewNixOSFlake() + switch cfg.ExecutorConfig.Type { + case "garnix": + systemAttr := "nixosConfigurations" if runtime.GOOS == "darwin" { - executor, err = executorPkg.NewNixDarwinFlake() + systemAttr = "darwinConfigurations" + } + executor, err = executorPkg.NewGarnixExecutor(cfg.ExecutorConfig.GarnixConfig, systemAttr) + 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" { + 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 ae2bf29..a31b3fe 100644 --- a/docs/generated-module-options.md +++ b/docs/generated-module-options.md @@ -252,6 +252,314 @@ 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 `\. + +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:* +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\.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_prefix + + + +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:* +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, garnix or hydra)\. + + + +*Type:* +one of “nix”, “garnix”, “hydra” + + + +*Default:* + +```nix +"nix" +``` + + + ## services\.comin\.exporter diff --git a/go.mod b/go.mod index a405b6c..36c0e82 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 68b5548..2455f8b 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/builder/builder.go b/internal/builder/builder.go index ad94864..bd276db 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 ed1ec6d..858e5f8 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 8d0a475..23e2792 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,36 @@ 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", "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.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/config/config_test.go b/internal/config/config_test.go index 4952591..c054007 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 cf32200..c8b0579 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/executor.go b/internal/executor/executor.go index 610d141..81663a9 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 a6f9ec1..d99f3b2 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 new file mode 100644 index 0000000..9fdb7c5 --- /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, selectedBranchName, 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/hydra.go b/internal/executor/hydra.go new file mode 100644 index 0000000..1ca72f4 --- /dev/null +++ b/internal/executor/hydra.go @@ -0,0 +1,244 @@ +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 + jobsetPrefix 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, + jobsetPrefix: config.JobsetPrefix, + 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 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 + + for { + matchedButNotFinished := false + + 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: 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/executor/nix.go b/internal/executor/nix.go index 7be9498..051db08 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 e903ef9..543691d 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/executor/utils.go b/internal/executor/utils.go index 288afeb..0c7e0cf 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/manager/manager_test.go b/internal/manager/manager_test.go index d6a81c1..77f431a 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 6bcbab6..3612849 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -62,22 +62,45 @@ 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 HydraConfig struct { + 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 { + Type string `yaml:"type"` + GarnixConfig GarnixConfig `yaml:"garnix"` + HydraConfig HydraConfig `yaml:"hydra"` +} + 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 5df9558..499eba7 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 c41b4fe..bc2f3f2 100644 --- a/nix/module-options.nix +++ b/nix/module-options.nix @@ -27,6 +27,14 @@ 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)."; + } + { + 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)."; + } ]; }) ]; @@ -381,6 +389,107 @@ 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`. + + 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 { + options = { + type = mkOption { + type = enum [ + "nix" + "garnix" + "hydra" + ]; + default = "nix"; + description = "Type of executor to use (nix, garnix or hydra)."; + }; + 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."; + }; + }; + }; + }; + 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_prefix = mkOption { + type = str; + default = ""; + description = '' + 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 { + 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."; + }; + }; + }; + }; + }; + }; + }; }; }; }