feat: add builder mode for centralized Nix builds
Add a new "builder" capability to trigger Nix builds on a dedicated build host via NATS messaging. This allows pre-building NixOS configurations before deployment. New components: - Builder mode: subscribes to build.<repo>.* subjects, executes nix build - Build CLI command: triggers builds with progress tracking - MCP build tool: available with --enable-builds flag - Builder metrics: tracks build success/failure per repo and host - NixOS module: services.homelab-deploy.builder The builder uses a YAML config file to define allowed repositories with their URLs and default branches. Builds can target all hosts or specific hosts, with real-time progress updates. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
140
internal/cli/build.go
Normal file
140
internal/cli/build.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.t-juice.club/torjus/homelab-deploy/internal/messages"
|
||||
"git.t-juice.club/torjus/homelab-deploy/internal/nats"
|
||||
)
|
||||
|
||||
// BuildConfig holds configuration for a build operation.
|
||||
type BuildConfig struct {
|
||||
NATSUrl string
|
||||
NKeyFile string
|
||||
Repo string
|
||||
Target string
|
||||
Branch string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// BuildResult contains the aggregated results from a build.
|
||||
type BuildResult struct {
|
||||
Responses []*messages.BuildResponse
|
||||
FinalResponse *messages.BuildResponse
|
||||
Errors []error
|
||||
}
|
||||
|
||||
// AllSucceeded returns true if the build completed successfully.
|
||||
func (r *BuildResult) AllSucceeded() bool {
|
||||
if len(r.Errors) > 0 {
|
||||
return false
|
||||
}
|
||||
if r.FinalResponse == nil {
|
||||
return false
|
||||
}
|
||||
return r.FinalResponse.Status == messages.BuildStatusCompleted && r.FinalResponse.Failed == 0
|
||||
}
|
||||
|
||||
// MarshalJSON returns the JSON representation of the build result.
|
||||
func (r *BuildResult) MarshalJSON() ([]byte, error) {
|
||||
if r.FinalResponse != nil {
|
||||
return json.Marshal(r.FinalResponse)
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"status": "unknown",
|
||||
"responses": r.Responses,
|
||||
"errors": r.Errors,
|
||||
})
|
||||
}
|
||||
|
||||
// Build triggers a build and collects responses.
|
||||
func Build(ctx context.Context, cfg BuildConfig, onResponse func(*messages.BuildResponse)) (*BuildResult, error) {
|
||||
// Connect to NATS
|
||||
client, err := nats.Connect(nats.Config{
|
||||
URL: cfg.NATSUrl,
|
||||
NKeyFile: cfg.NKeyFile,
|
||||
Name: "homelab-deploy-build-cli",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Generate unique reply subject
|
||||
requestID := uuid.New().String()
|
||||
replySubject := fmt.Sprintf("build.responses.%s", requestID)
|
||||
|
||||
var mu sync.Mutex
|
||||
result := &BuildResult{}
|
||||
done := make(chan struct{})
|
||||
|
||||
// Subscribe to reply subject
|
||||
sub, err := client.Subscribe(replySubject, func(subject string, data []byte) {
|
||||
resp, err := messages.UnmarshalBuildResponse(data)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
result.Errors = append(result.Errors, fmt.Errorf("failed to unmarshal response: %w", err))
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
result.Responses = append(result.Responses, resp)
|
||||
if resp.Status.IsFinal() {
|
||||
result.FinalResponse = resp
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if onResponse != nil {
|
||||
onResponse(resp)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to subscribe to reply subject: %w", err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
// Build and send request
|
||||
req := &messages.BuildRequest{
|
||||
Repo: cfg.Repo,
|
||||
Target: cfg.Target,
|
||||
Branch: cfg.Branch,
|
||||
ReplyTo: replySubject,
|
||||
}
|
||||
|
||||
data, err := req.Marshal()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Publish to build.<repo>.<target>
|
||||
buildSubject := fmt.Sprintf("build.%s.%s", cfg.Repo, cfg.Target)
|
||||
if err := client.Publish(buildSubject, data); err != nil {
|
||||
return nil, fmt.Errorf("failed to publish request: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Flush(); err != nil {
|
||||
return nil, fmt.Errorf("failed to flush: %w", err)
|
||||
}
|
||||
|
||||
// Wait for final response or timeout
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return result, ctx.Err()
|
||||
case <-done:
|
||||
return result, nil
|
||||
case <-time.After(cfg.Timeout):
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user