Skip to content

Commit 93071ad

Browse files
committed
preflight: add local-side checks for nodes with build:
trond preflight previously only verified the deploy target. With the build pipeline, the LOCAL machine has its own prerequisites — git to resolve revisions, docker daemon for --builder docker, java + ./gradlew for --builder host, the source path being a real git repo. Surfacing these as preflight failures catches "trond apply" problems before any container starts. New checks (added when at least one node has a build: block): build-git git on local PATH build-source-<dir> source path exists + is a git repo build-docker-local local docker daemon reachable (--builder docker) build-host-jdk java on local PATH (--builder host) build-host-gradlew-<dir> ./gradlew exists + executable (--builder host) Shared checks (git, docker-local, host-jdk) fire once even if multiple nodes share the same setup. Per-source checks dedupe on resolved absolute path so a multi-node intent referencing the same source doesn't repeat itself. Build-less intents are unaffected: preflightBuildChecks returns nil when no node has a build block, so existing target-only intents see the identical preflight output they always had. Tests: TestPreflightBuildChecks_{NoBuildBlock,HostBuilderHappyPath, DockerBuilderTriggersDockerCheck}, TestCheckBuildSource (4 sub), TestCheckSourceGradlew (3 sub, skip on Windows), TestResolveBuild SourceForPreflight (3 sub) — 13 sub-cases total.
1 parent 50bc2b6 commit 93071ad

3 files changed

Lines changed: 498 additions & 0 deletions

File tree

cmd/preflight.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ func runPreflight(cmd *cobra.Command, args []string) error {
8080
checks = append(checks, checkPorts(cmd, tgt, &node)...)
8181
}
8282

83+
// Build-side checks (LOCAL machine, not the target). Only runs
84+
// when at least one node has a `build:` block; build-less intents
85+
// pay no cost. See preflight_build.go for the policy details.
86+
checks = append(checks, preflightBuildChecks(cmd.Context(), parsed, preflightIntentPath)...)
87+
8388
for _, c := range checks {
8489
if c.Status == "fail" {
8590
allPassed = false

cmd/preflight_build.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/tronprotocol/tron-deployment/internal/intent"
12+
)
13+
14+
// preflightBuildChecks adds LOCAL-side checks for any node that
15+
// carries a `build:` block. Distinct from the existing target-side
16+
// preflights (those verify the deploy host; these verify the machine
17+
// running `trond` itself, since builds always run locally and
18+
// transfer artifacts to the target via the SSH path).
19+
//
20+
// Returns an empty slice when no node has a build block — the
21+
// caller appends unconditionally so build-less intents pay no
22+
// preflight cost.
23+
//
24+
// Check naming: `build-<dim>` so a `trond preflight -o json` consumer
25+
// can group/filter on the prefix. Failures here gate apply just like
26+
// any other preflight failure (overall=fail → ExitPreflightFailure).
27+
func preflightBuildChecks(ctx context.Context, parsed *intent.Intent, intentPath string) []checkResult {
28+
var checks []checkResult
29+
30+
// Aggregate: track what builders are in use and what unique
31+
// source dirs the build pipeline will read from. Lets us run
32+
// each shared check (git on PATH, docker reachable, java on
33+
// PATH) ONCE no matter how many nodes share the same setup.
34+
type buildPlan struct {
35+
sourceResolved string
36+
builder string // "docker" | "host"
37+
}
38+
var plans []buildPlan
39+
for _, n := range parsed.Nodes {
40+
if n.Build == nil {
41+
continue
42+
}
43+
src := resolveBuildSourceForPreflight(n.Build.Source, intentPath)
44+
builder := n.Build.Builder
45+
if builder == "" {
46+
builder = "docker" // matches builder.go's withDefaults
47+
}
48+
plans = append(plans, buildPlan{sourceResolved: src, builder: builder})
49+
}
50+
if len(plans) == 0 {
51+
return nil
52+
}
53+
54+
// Shared check: git is on PATH. The build pipeline shells out to
55+
// `git rev-parse` to resolve revisions + compute patch hashes,
56+
// so a missing git binary breaks every build.
57+
checks = append(checks, checkLocalGit())
58+
59+
// Per-source: source path exists, is a directory, has a .git
60+
// (or is inside one). Without git metadata, source.Resolve fails
61+
// before the runner gets a chance.
62+
checked := map[string]bool{}
63+
for _, p := range plans {
64+
if checked[p.sourceResolved] {
65+
continue
66+
}
67+
checked[p.sourceResolved] = true
68+
checks = append(checks, checkBuildSource(p.sourceResolved))
69+
}
70+
71+
// Builder-specific:
72+
// docker → ensure a docker daemon is reachable locally (the
73+
// builder container needs to launch here, NOT on the deploy
74+
// target). This is separate from the existing target-side
75+
// `checkDocker` which probes the SSH host.
76+
// host → ensure java is on local PATH (for builder identity)
77+
// AND each source has a ./gradlew (the runner refuses
78+
// otherwise). java check is shared; gradlew is per-source.
79+
needLocalDocker := false
80+
needLocalJava := false
81+
for _, p := range plans {
82+
switch p.builder {
83+
case "docker":
84+
needLocalDocker = true
85+
case "host":
86+
needLocalJava = true
87+
}
88+
}
89+
if needLocalDocker {
90+
checks = append(checks, checkLocalDocker(ctx))
91+
}
92+
if needLocalJava {
93+
checks = append(checks, checkLocalJava(ctx))
94+
}
95+
if needLocalJava {
96+
checkedGW := map[string]bool{}
97+
for _, p := range plans {
98+
if p.builder != "host" || checkedGW[p.sourceResolved] {
99+
continue
100+
}
101+
checkedGW[p.sourceResolved] = true
102+
checks = append(checks, checkSourceGradlew(p.sourceResolved))
103+
}
104+
}
105+
return checks
106+
}
107+
108+
// resolveBuildSourceForPreflight applies FR-021's intent-relative path
109+
// resolution. Mirrors internal/apply/build.go's resolveBuildSource
110+
// but doesn't error on the empty case (preflight wants to surface
111+
// it as a check failure, not an early-return).
112+
func resolveBuildSourceForPreflight(source, intentPath string) string {
113+
if source == "" {
114+
return ""
115+
}
116+
if filepath.IsAbs(source) {
117+
return filepath.Clean(source)
118+
}
119+
if intentPath != "" {
120+
return filepath.Clean(filepath.Join(filepath.Dir(intentPath), source))
121+
}
122+
if abs, err := filepath.Abs(source); err == nil {
123+
return abs
124+
}
125+
return source
126+
}
127+
128+
func checkLocalGit() checkResult {
129+
out, err := exec.Command("git", "--version").Output()
130+
if err != nil {
131+
return checkResult{
132+
Name: "build-git",
133+
Status: "fail",
134+
Message: "git not found on local PATH; required to resolve revisions and compute patch hashes",
135+
}
136+
}
137+
return checkResult{
138+
Name: "build-git",
139+
Status: "pass",
140+
Message: strings.TrimSpace(string(out)),
141+
}
142+
}
143+
144+
func checkBuildSource(path string) checkResult {
145+
name := "build-source"
146+
if base := filepath.Base(path); base != "" && base != "." && base != "/" {
147+
name = "build-source-" + base
148+
}
149+
if path == "" {
150+
return checkResult{
151+
Name: name,
152+
Status: "fail",
153+
Message: "build.source is empty; set the path to your java-tron checkout",
154+
}
155+
}
156+
info, err := os.Stat(path)
157+
if err != nil {
158+
return checkResult{
159+
Name: name,
160+
Status: "fail",
161+
Message: fmt.Sprintf("%s: %s", path, err.Error()),
162+
}
163+
}
164+
if !info.IsDir() {
165+
return checkResult{
166+
Name: name,
167+
Status: "fail",
168+
Message: fmt.Sprintf("%s is not a directory", path),
169+
}
170+
}
171+
// Either the source root has .git/ OR `git -C <src> rev-parse`
172+
// succeeds (covers submodules + working-tree checkouts). Falling
173+
// back to the git call keeps the check accurate for less common
174+
// layouts without us re-implementing git's walk logic.
175+
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
176+
return checkResult{Name: name, Status: "pass", Message: path}
177+
}
178+
if err := exec.Command("git", "-C", path, "rev-parse", "--git-dir").Run(); err != nil {
179+
return checkResult{
180+
Name: name,
181+
Status: "fail",
182+
Message: fmt.Sprintf("%s is not a git repository (run `git init` or `git clone`)", path),
183+
}
184+
}
185+
return checkResult{Name: name, Status: "pass", Message: path}
186+
}
187+
188+
func checkLocalDocker(ctx context.Context) checkResult {
189+
// `docker version --format` is a daemon ping — fails when the
190+
// CLI is present but the daemon isn't, which is the failure mode
191+
// we actually care about (a docker-builder run would hang on
192+
// `docker run`).
193+
out, err := exec.CommandContext(ctx, "docker", "version", "--format", "{{.Server.Version}}").Output()
194+
if err != nil {
195+
return checkResult{
196+
Name: "build-docker-local",
197+
Status: "fail",
198+
Message: "docker daemon not reachable from this host; required for --builder docker",
199+
}
200+
}
201+
return checkResult{
202+
Name: "build-docker-local",
203+
Status: "pass",
204+
Message: "docker server " + strings.TrimSpace(string(out)),
205+
}
206+
}
207+
208+
func checkLocalJava(ctx context.Context) checkResult {
209+
out, err := exec.CommandContext(ctx, "java", "-version").CombinedOutput()
210+
if err != nil {
211+
return checkResult{
212+
Name: "build-host-jdk",
213+
Status: "fail",
214+
Message: "java not on local PATH; required for --builder host",
215+
}
216+
}
217+
first := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
218+
return checkResult{
219+
Name: "build-host-jdk",
220+
Status: "pass",
221+
Message: first,
222+
}
223+
}
224+
225+
func checkSourceGradlew(srcPath string) checkResult {
226+
name := "build-host-gradlew"
227+
if base := filepath.Base(srcPath); base != "" && base != "." && base != "/" {
228+
name = "build-host-gradlew-" + base
229+
}
230+
gradlewPath := filepath.Join(srcPath, "gradlew")
231+
info, err := os.Stat(gradlewPath)
232+
if err != nil {
233+
return checkResult{
234+
Name: name,
235+
Status: "fail",
236+
Message: fmt.Sprintf("%s not found; --builder host requires a gradle wrapper (run `gradle wrapper` in the source tree)", gradlewPath),
237+
}
238+
}
239+
// Executable bit check — a non-executable gradlew leads to a
240+
// confusing "permission denied" at build time. Surface it now.
241+
if info.Mode()&0o111 == 0 {
242+
return checkResult{
243+
Name: name,
244+
Status: "fail",
245+
Message: fmt.Sprintf("%s is not executable (chmod +x gradlew)", gradlewPath),
246+
}
247+
}
248+
return checkResult{Name: name, Status: "pass", Message: gradlewPath}
249+
}

0 commit comments

Comments
 (0)