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:
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/homelab-deploy/internal/builder"
|
||||
deploycli "git.t-juice.club/torjus/homelab-deploy/internal/cli"
|
||||
"git.t-juice.club/torjus/homelab-deploy/internal/listener"
|
||||
"git.t-juice.club/torjus/homelab-deploy/internal/mcp"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const version = "0.1.13"
|
||||
const version = "0.2.0"
|
||||
|
||||
func main() {
|
||||
app := &cli.Command{
|
||||
@@ -25,8 +26,10 @@ func main() {
|
||||
Version: version,
|
||||
Commands: []*cli.Command{
|
||||
listenerCommand(),
|
||||
builderCommand(),
|
||||
mcpCommand(),
|
||||
deployCommand(),
|
||||
buildCommand(),
|
||||
listHostsCommand(),
|
||||
},
|
||||
}
|
||||
@@ -175,6 +178,10 @@ func mcpCommand() *cli.Command {
|
||||
Usage: "Timeout in seconds for deployment operations",
|
||||
Value: 900,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "enable-builds",
|
||||
Usage: "Enable build tool",
|
||||
},
|
||||
},
|
||||
Action: func(_ context.Context, c *cli.Command) error {
|
||||
enableAdmin := c.Bool("enable-admin")
|
||||
@@ -189,6 +196,7 @@ func mcpCommand() *cli.Command {
|
||||
NKeyFile: c.String("nkey-file"),
|
||||
EnableAdmin: enableAdmin,
|
||||
AdminNKeyFile: adminNKeyFile,
|
||||
EnableBuilds: c.Bool("enable-builds"),
|
||||
DiscoverSubject: c.String("discover-subject"),
|
||||
Timeout: time.Duration(c.Int("timeout")) * time.Second,
|
||||
}
|
||||
@@ -374,3 +382,196 @@ func listHostsCommand() *cli.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builderCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "builder",
|
||||
Usage: "Run as a build server (systemd service mode)",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "nats-url",
|
||||
Usage: "NATS server URL",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "nkey-file",
|
||||
Usage: "Path to NKey seed file for NATS authentication",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Usage: "Path to builder configuration file",
|
||||
Required: true,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "timeout",
|
||||
Usage: "Build timeout in seconds per host",
|
||||
Value: 1800,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "metrics-enabled",
|
||||
Usage: "Enable Prometheus metrics endpoint",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "metrics-addr",
|
||||
Usage: "Address for Prometheus metrics HTTP server",
|
||||
Value: ":9973",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
repoCfg, err := builder.LoadConfig(c.String("config"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
cfg := builder.BuilderConfig{
|
||||
NATSUrl: c.String("nats-url"),
|
||||
NKeyFile: c.String("nkey-file"),
|
||||
ConfigFile: c.String("config"),
|
||||
Timeout: time.Duration(c.Int("timeout")) * time.Second,
|
||||
MetricsEnabled: c.Bool("metrics-enabled"),
|
||||
MetricsAddr: c.String("metrics-addr"),
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
b := builder.New(cfg, repoCfg, logger)
|
||||
|
||||
// Handle shutdown signals
|
||||
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "build",
|
||||
Usage: "Trigger a build on the build server",
|
||||
ArgsUsage: "<repo> [hostname]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "nats-url",
|
||||
Usage: "NATS server URL",
|
||||
Sources: cli.EnvVars("HOMELAB_DEPLOY_NATS_URL"),
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "nkey-file",
|
||||
Usage: "Path to NKey seed file for NATS authentication",
|
||||
Sources: cli.EnvVars("HOMELAB_DEPLOY_NKEY_FILE"),
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "branch",
|
||||
Usage: "Git branch to build (uses repo default if not specified)",
|
||||
Sources: cli.EnvVars("HOMELAB_DEPLOY_BRANCH"),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "all",
|
||||
Usage: "Build all hosts in the repo",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "timeout",
|
||||
Usage: "Timeout in seconds for collecting responses",
|
||||
Sources: cli.EnvVars("HOMELAB_DEPLOY_BUILD_TIMEOUT"),
|
||||
Value: 3600,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "json",
|
||||
Usage: "Output results as JSON",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() < 1 {
|
||||
return fmt.Errorf("repo argument required")
|
||||
}
|
||||
|
||||
repo := c.Args().First()
|
||||
target := c.Args().Get(1)
|
||||
all := c.Bool("all")
|
||||
|
||||
if target == "" && !all {
|
||||
return fmt.Errorf("must specify hostname or --all")
|
||||
}
|
||||
if target != "" && all {
|
||||
return fmt.Errorf("cannot specify both hostname and --all")
|
||||
}
|
||||
if all {
|
||||
target = "all"
|
||||
}
|
||||
|
||||
cfg := deploycli.BuildConfig{
|
||||
NATSUrl: c.String("nats-url"),
|
||||
NKeyFile: c.String("nkey-file"),
|
||||
Repo: repo,
|
||||
Target: target,
|
||||
Branch: c.String("branch"),
|
||||
Timeout: time.Duration(c.Int("timeout")) * time.Second,
|
||||
}
|
||||
|
||||
jsonOutput := c.Bool("json")
|
||||
if !jsonOutput {
|
||||
branchStr := cfg.Branch
|
||||
if branchStr == "" {
|
||||
branchStr = "(default)"
|
||||
}
|
||||
fmt.Printf("Building %s target=%s branch=%s\n", repo, target, branchStr)
|
||||
}
|
||||
|
||||
// Handle shutdown signals
|
||||
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
result, err := deploycli.Build(ctx, cfg, func(resp *messages.BuildResponse) {
|
||||
if jsonOutput {
|
||||
return
|
||||
}
|
||||
switch resp.Status {
|
||||
case messages.BuildStatusStarted:
|
||||
fmt.Printf("Started: %s\n", resp.Message)
|
||||
case messages.BuildStatusProgress:
|
||||
successStr := "..."
|
||||
if resp.HostSuccess != nil {
|
||||
if *resp.HostSuccess {
|
||||
successStr = "success"
|
||||
} else {
|
||||
successStr = "failed"
|
||||
}
|
||||
}
|
||||
fmt.Printf("[%d/%d] %s: %s\n", resp.HostsCompleted, resp.HostsTotal, resp.Host, successStr)
|
||||
case messages.BuildStatusCompleted, messages.BuildStatusFailed:
|
||||
fmt.Printf("\n%s\n", resp.Message)
|
||||
case messages.BuildStatusRejected:
|
||||
fmt.Printf("Rejected: %s\n", resp.Message)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build failed: %w", err)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
data, err := result.MarshalJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else if result.FinalResponse != nil {
|
||||
fmt.Printf("\nBuild complete: %d succeeded, %d failed (%.1fs)\n",
|
||||
result.FinalResponse.Succeeded,
|
||||
result.FinalResponse.Failed,
|
||||
result.FinalResponse.TotalDurationSeconds)
|
||||
}
|
||||
|
||||
if !result.AllSucceeded() {
|
||||
return fmt.Errorf("some builds failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user