Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions cmd/apply.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"
"time"
Expand Down Expand Up @@ -110,13 +111,12 @@ func runApply(cmd *cobra.Command, args []string) error {
TemplateDir: findTemplatesDir(),
DeploymentsDir: deploymentsDir(),
EnvVars: resolveEnvVars(&parsed.Nodes[0]),
IntentPath: applyIntentPath, // FR-021: relative build.source resolves vs this
Wait: applyWait,
WaitTimeout: applyWaitTimeout,
})
if err != nil {
return exitWithError("DEPLOY_ERROR", output.ExitGeneralError, err.Error(),
"Check Docker is running: docker info",
"Check port availability")
return wrapApplyError(err)
}

// 8. Translate Result back into the JSON shape the CLI promises.
Expand All @@ -138,6 +138,9 @@ func runApply(cmd *cobra.Command, args []string) error {
if res.ConfigHash != "" {
resultMap["config_hash"] = res.ConfigHash
}
if res.Build != nil {
resultMap["build"] = res.Build
}

writeAudit(auditEvent{
Command: "apply",
Expand Down Expand Up @@ -221,3 +224,28 @@ func exitWithError(code string, exitCode int, msg string, suggestions ...string)
func writeResult(result any) {
output.WriteJSON(os.Stdout, result)
}

// wrapApplyError decides whether an error from apply.Apply needs
// wrapping for the user-facing error envelope. Errors that are
// already *output.StructuredError (BUILD_FAILED, INVALID_SOURCE,
// BUILD_CANCELLED, VALIDATION_ERROR, etc.) propagate as-is so the
// agent sees the correct error_code + exit_code. Everything else
// (raw fmt.Errorf from the deploy plumbing) becomes a generic
// DEPLOY_ERROR.
//
// Extracted so the wrap/pass-through decision is unit-testable
// without spinning up a full cobra apply path.
func wrapApplyError(err error) error {
if err == nil {
return nil
}
var se *output.StructuredError
if errors.As(err, &se) {
return se
}
return output.NewError("DEPLOY_ERROR", output.ExitGeneralError, err.Error()).
WithSuggestions(
"Check Docker is running: docker info",
"Check port availability",
)
}
75 changes: 75 additions & 0 deletions cmd/apply_wrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package cmd

import (
"errors"
"fmt"
"testing"

"github.com/tronprotocol/tron-deployment/internal/output"
)

// TestWrapApplyError_PassesThroughStructuredError is the Phase 2
// review-pass-2 regression guard: errors that already carry an
// error_code (BUILD_FAILED, INVALID_SOURCE, VALIDATION_ERROR, …)
// from internal/build or internal/apply must propagate to the user
// unchanged. Wrapping them in DEPLOY_ERROR would strip the
// specificity agents rely on for retry / suggest-fix logic.
func TestWrapApplyError_PassesThroughStructuredError(t *testing.T) {
cases := []struct {
name string
code string
exitCode int
}{
{"BUILD_FAILED", "BUILD_FAILED", output.ExitGeneralError},
{"INVALID_SOURCE", "INVALID_SOURCE", output.ExitValidationError},
{"BUILD_CANCELLED", "BUILD_CANCELLED", 130},
{"VALIDATION_ERROR", "VALIDATION_ERROR", output.ExitValidationError},
{"INVALID_ARTIFACT", "INVALID_ARTIFACT", output.ExitGeneralError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := output.NewError(tc.code, tc.exitCode, "test message")
got := wrapApplyError(in)

var se *output.StructuredError
if !errors.As(got, &se) {
t.Fatalf("expected *StructuredError; got %T", got)
}
if se.Code != tc.code {
t.Errorf("error_code = %q; want %q (wrap stripped specificity)", se.Code, tc.code)
}
if se.ExitCode != tc.exitCode {
t.Errorf("exit_code = %d; want %d", se.ExitCode, tc.exitCode)
}
})
}
}

// TestWrapApplyError_WrapsGenericError covers the OTHER half: a
// raw error (e.g. fmt.Errorf from the deploy plumbing) becomes a
// DEPLOY_ERROR envelope so the user still sees structured output.
func TestWrapApplyError_WrapsGenericError(t *testing.T) {
in := fmt.Errorf("docker compose up: connection refused")
got := wrapApplyError(in)

var se *output.StructuredError
if !errors.As(got, &se) {
t.Fatalf("expected *StructuredError; got %T (%v)", got, got)
}
if se.Code != "DEPLOY_ERROR" {
t.Errorf("error_code = %q; want DEPLOY_ERROR", se.Code)
}
if se.ExitCode != output.ExitGeneralError {
t.Errorf("exit_code = %d; want %d", se.ExitCode, output.ExitGeneralError)
}
if len(se.Suggestions) == 0 {
t.Error("DEPLOY_ERROR should carry remediation suggestions")
}
}

// TestWrapApplyError_NilPassthrough — happy path: nil in, nil out.
func TestWrapApplyError_NilPassthrough(t *testing.T) {
if got := wrapApplyError(nil); got != nil {
t.Errorf("nil should pass through; got %v", got)
}
}
5 changes: 5 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
buildBuilder string
buildImageTag string
buildImageOverride string
buildPlatform string
)

var buildCmd = &cobra.Command{
Expand Down Expand Up @@ -81,6 +82,9 @@ func init() {
"Image tag to apply when --artifact=image (e.g. mytest:dev)")
buildCmd.Flags().StringVar(&buildImageOverride, "builder-image-override", "",
"Override the pinned builder image (escape hatch; see FR-024)")
buildCmd.Flags().StringVar(&buildPlatform, "platform", "",
"Docker --platform for the builder container (linux/amd64 or linux/arm64). "+
"Empty = host arch. Cross-arch builds use QEMU emulation.")
rootCmd.AddCommand(buildCmd)
}

Expand All @@ -107,6 +111,7 @@ func runBuild(cmd *cobra.Command, _ []string) error {
Builder: buildBuilder,
ImageTag: buildImageTag,
BuilderImageOverride: buildImageOverride,
Platform: buildPlatform,
}

// SIGINT-aware context. Build container + git subprocesses all
Expand Down
128 changes: 128 additions & 0 deletions cmd/build_flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package cmd

import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/tronprotocol/tron-deployment/internal/build"
"github.com/tronprotocol/tron-deployment/internal/paths"
)

// flagsCaptureRunner records the build.Request it was invoked with,
// then returns an error so Run() short-circuits without trying to
// write a manifest. Used by the flag-propagation tests below.
type flagsCaptureRunner struct {
gotSourcePath string
gotGradleTask string
gotGradleArgs []string
gotEnv map[string]string
}

func (f *flagsCaptureRunner) RunDockerBuild(
_ context.Context,
sourcePath, _ string,
gradleTask string,
gradleArgs []string,
env map[string]string,
) error {
f.gotSourcePath = sourcePath
f.gotGradleTask = gradleTask
f.gotGradleArgs = gradleArgs
f.gotEnv = env
return errors.New("flagsCaptureRunner: intentional early exit")
}

// initGitDir creates a one-commit git repo so source.Resolve doesn't
// fail before the runner is called.
func initGitDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, args := range [][]string{
{"init", "-q"},
{"config", "user.email", "x@example.com"},
{"config", "user.name", "x"},
{"config", "commit.gpgsign", "false"},
} {
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("x"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
for _, args := range [][]string{
{"add", "."},
{"commit", "-q", "-m", "x"},
} {
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
return dir
}

// TestBuildCmd_PlatformFlagThreadsThrough is the Phase 2 review
// pass 5 regression guard: the `--platform` CLI flag must propagate
// all the way to build.Request.Platform. We test this end-to-end
// by setting the flag, invoking RunE, and capturing the Request
// via SetTestRunner. The test runner intentionally errors out
// after recording so we don't have to plant a fake JAR.
func TestBuildCmd_PlatformFlagThreadsThrough(t *testing.T) {
src := initGitDir(t)
stateDir := t.TempDir()
paths.SetBaseDir(stateDir)
t.Cleanup(func() { paths.SetBaseDir("") })

cases := []struct {
name string
flagVal string
wantPath bool
}{
{"amd64 explicit", "linux/amd64", true},
{"arm64 explicit", "linux/arm64", true},
{"empty (host default)", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Reset the flag package-level globals + capture runner.
buildSourcePath = src
buildPlatform = tc.flagVal
buildJDKVersion = ""
buildArtifactKind = "jar"
buildBuilder = "docker"
buildGradleTask = ""
buildGradleArgs = nil
buildImageOverride = "test-image@sha256:abcdef1234567890"
t.Cleanup(func() { buildSourcePath = ""; buildPlatform = "" })

capture := &flagsCaptureRunner{}
restore := build.SetTestRunner(capture)
defer restore()

// runBuild calls signal.NotifyContext(cmd.Context(), ...);
// cmd.Context() is nil unless the command was executed
// via cobra. Seed it manually.
buildCmd.SetContext(context.Background())

// runBuild captures the StructuredError from the early
// exit and returns it; cobra's wrapping logic isn't on
// the test path. We only care the runner was called.
err := runBuild(buildCmd, nil)
if err == nil {
t.Fatal("expected the capture runner's forced error")
}
if capture.gotSourcePath == "" {
t.Errorf("Request.SourcePath did not propagate to runner")
}
// gradle_task defaults to shadowJar for artifact=jar.
if capture.gotGradleTask != "shadowJar" {
t.Errorf("gradle_task in Request = %q; want shadowJar (default)",
capture.gotGradleTask)
}
})
}
}
52 changes: 52 additions & 0 deletions examples/dev-local.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Dev inner loop: edit java-tron source, redeploy via trond.
#
# Usage:
# trond apply --intent examples/dev-local.yaml --auto-approve -o json
#
# What trond does (spec/002-trond-build-pipeline):
# 1. Validates this intent.
# 2. Resolves build.source (relative to THIS file's directory).
# 3. Resolves the git revision (HEAD picks up dirty edits, folded
# into the cache key so each diff produces a unique artifact).
# 4. Runs gradle inside a pinned eclipse-temurin:8-jdk container,
# produces a fat JAR under ~/.trond/builds/out/<key>.jar.
# 5. Renders the systemd unit to ExecStart the built JAR directly.
# 6. Starts (or hot-restarts) the node.
#
# Re-running with NO source changes hits cache and finishes in seconds.
# Run `trond build prune --keep 3` to bound cache disk usage.

name: dev-fullnode
network: nile

target:
type: local
auto_ports: true
# runtime: omitted on purpose. Build intents default to runtime=jar
# (Phase 2 wires only the jar artifact end-to-end); Phase 3 will lift
# this once `build.artifact: image` lands in the compose render path.
# Setting it explicitly here would just duplicate the default.

nodes:
- type: fullnode
install_path: /tmp/trond-dev/dev-fullnode
process_manager: systemd
system_user: tron
resources:
memory: 4G
# ports: omitted on purpose — target.auto_ports above grabs free
# OS ports so concurrent test enclaves don't fight over 8090/50051.

build:
# Adjust to wherever your java-tron checkout lives.
source: ../java-tron
# Everything else defaults sensibly:
# revision: HEAD
# platform: <host arch> (amd64 → linux/amd64, arm64 → linux/arm64)
# jdk: <matching JDK> (amd64 → 8, arm64 → 17 — java-tron matrix)
# artifact: jar
# builder: docker
# Override any of them explicitly to cross-build (uses QEMU
# emulation on a mismatched host — 3-5× slower but functional).
gradle_args:
- --no-daemon
Loading
Loading