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
27 changes: 19 additions & 8 deletions cmd/mxcli/docker/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,28 @@ func mxbuildBinaryName() string {
return "mxbuild"
}

// mxbuildBinaryNames returns all candidate binary names for mxbuild.
// On Windows, the Linux binary ("mxbuild") may also be cached when downloaded
// for Docker, so both names must be checked.
func mxbuildBinaryNames() []string {
if runtime.GOOS == "windows" {
return []string{"mxbuild.exe", "mxbuild"}
}
return []string{"mxbuild"}
}

// findMxBuildInDir looks for the mxbuild binary inside a directory.
// Checks: dir/mxbuild, dir/modeler/mxbuild (Mendix installation layout).
func findMxBuildInDir(dir string) string {
bin := mxbuildBinaryName()
candidates := []string{
filepath.Join(dir, bin),
filepath.Join(dir, "modeler", bin),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
return c
for _, bin := range mxbuildBinaryNames() {
candidates := []string{
filepath.Join(dir, bin),
filepath.Join(dir, "modeler", bin),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
return c
}
}
}
return ""
Expand Down
36 changes: 24 additions & 12 deletions cmd/mxcli/docker/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,30 +38,35 @@ func MxBuildCDNURL(version, goarch string) string {

// CachedMxBuildPath returns the path to a cached mxbuild binary for the given version,
// or empty string if not cached.
// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker).
func CachedMxBuildPath(version string) string {
cacheDir, err := MxBuildCacheDir(version)
if err != nil {
return ""
}
bin := filepath.Join(cacheDir, "modeler", mxbuildBinaryName())
if info, err := os.Stat(bin); err == nil && !info.IsDir() {
return bin
for _, name := range mxbuildBinaryNames() {
bin := filepath.Join(cacheDir, "modeler", name)
if info, err := os.Stat(bin); err == nil && !info.IsDir() {
return bin
}
}
return ""
}

// AnyCachedMxBuildPath searches for any cached mxbuild version.
// Returns the path to the first mxbuild binary found, or empty string.
// On Windows, checks both "mxbuild.exe" and "mxbuild" (Linux binary cached for Docker).
func AnyCachedMxBuildPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", mxbuildBinaryName())
matches, _ := filepath.Glob(pattern)
if len(matches) > 0 {
// Return the last match (likely newest version by lexicographic sort)
return matches[len(matches)-1]
for _, name := range mxbuildBinaryNames() {
pattern := filepath.Join(home, ".mxcli", "mxbuild", "*", "modeler", name)
matches, _ := filepath.Glob(pattern)
if len(matches) > 0 {
return matches[len(matches)-1]
}
}
return ""
}
Expand Down Expand Up @@ -113,11 +118,18 @@ func DownloadMxBuild(version string, w io.Writer) (string, error) {
return "", fmt.Errorf("extracting mxbuild: %w", err)
}

// Verify the binary exists
bin := filepath.Join(cacheDir, "modeler", mxbuildBinaryName())
if _, err := os.Stat(bin); err != nil {
// Verify the binary exists (check all candidate names)
var bin string
for _, name := range mxbuildBinaryNames() {
candidate := filepath.Join(cacheDir, "modeler", name)
if _, err := os.Stat(candidate); err == nil {
bin = candidate
break
}
}
if bin == "" {
os.RemoveAll(cacheDir)
return "", fmt.Errorf("mxbuild binary not found after extraction (expected %s)", bin)
return "", fmt.Errorf("mxbuild binary not found after extraction (looked in %s/modeler/)", cacheDir)
}

fmt.Fprintf(w, " MxBuild cached at %s\n", bin)
Expand Down
51 changes: 51 additions & 0 deletions cmd/mxcli/docker/reload.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"strings"
"time"
)

Expand Down Expand Up @@ -110,9 +111,59 @@ func Reload(opts ReloadOptions) error {
fmt.Fprintln(w, "Model reloaded.")
}

// Check for pending schema changes (new/dropped entities or attributes).
// reload_model only reloads the in-memory model definition; it does not
// sync the database schema. If DDL changes are pending, the app will crash
// at runtime with "Entity does not exist" or similar errors.
if pending := checkPendingDDL(m2eeOpts); pending != "" {
fmt.Fprintln(w, "")
fmt.Fprintln(w, "WARNING: Database schema changes detected after reload.")
fmt.Fprintln(w, " The model was reloaded, but new entities or attributes require")
fmt.Fprintln(w, " a database schema update that hot-reload cannot perform.")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " Pending DDL:")
for _, line := range strings.Split(pending, "\n") {
if strings.TrimSpace(line) != "" {
fmt.Fprintf(w, " %s\n", line)
}
}
fmt.Fprintln(w, "")
fmt.Fprintln(w, " Fix: run 'mxcli docker up --fresh' to restart with schema sync.")
}

return nil
}

// checkPendingDDL queries the runtime for pending DDL commands.
// Returns the DDL text if changes are pending, or empty string if none or on error.
func checkPendingDDL(opts M2EEOptions) string {
resp, err := CallM2EE(opts, "get_ddl_commands", nil)
if err != nil {
return ""
}
if resp.Result != 0 {
return ""
}

feedback := resp.Feedback()
if feedback == nil {
return ""
}

// The M2EE get_ddl_commands action returns DDL in feedback.ddl_commands
ddl, ok := feedback["ddl_commands"]
if !ok {
return ""
}

ddlStr, ok := ddl.(string)
if !ok || strings.TrimSpace(ddlStr) == "" {
return ""
}

return ddlStr
}

// extractReloadDuration extracts the duration from feedback.startup_metrics.duration.
func extractReloadDuration(feedback map[string]any) string {
if feedback == nil {
Expand Down
68 changes: 62 additions & 6 deletions cmd/mxcli/docker/reload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,20 @@ func TestReload_CSSOnly(t *testing.T) {
}

func TestReload_ModelOnly(t *testing.T) {
var receivedAction string
var actions []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
receivedAction = body["action"].(string)
action := body["action"].(string)
actions = append(actions, action)

w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
switch action {
case "reload_model":
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
case "get_ddl_commands":
w.Write([]byte(`{"result":0,"feedback":{}}`))
}
}))
defer server.Close()

Expand All @@ -77,8 +83,8 @@ func TestReload_ModelOnly(t *testing.T) {
t.Fatalf("Reload: %v", err)
}

if receivedAction != "reload_model" {
t.Errorf("expected reload_model action, got %q", receivedAction)
if len(actions) < 2 || actions[0] != "reload_model" || actions[1] != "get_ddl_commands" {
t.Errorf("expected actions [reload_model, get_ddl_commands], got %v", actions)
}
if !strings.Contains(stdout.String(), "Model reloaded") {
t.Errorf("expected 'Model reloaded' in output, got: %s", stdout.String())
Expand Down Expand Up @@ -142,8 +148,15 @@ func TestReload_ParseDuration(t *testing.T) {

func TestReload_ModelOnly_WithDuration(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
switch body["action"].(string) {
case "reload_model":
w.Write([]byte(`{"result":0,"feedback":{"startup_metrics":{"duration":98}}}`))
case "get_ddl_commands":
w.Write([]byte(`{"result":0,"feedback":{}}`))
}
}))
defer server.Close()

Expand Down Expand Up @@ -197,6 +210,49 @@ func TestReload_CSSOnly_Error(t *testing.T) {
}
}

func TestReload_ModelOnly_PendingDDL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
switch body["action"].(string) {
case "reload_model":
w.Write([]byte(`{"result":0,"feedback":{}}`))
case "get_ddl_commands":
w.Write([]byte(`{"result":0,"feedback":{"ddl_commands":"CREATE TABLE mymodule$customer (id BIGINT NOT NULL);\nALTER TABLE mymodule$order ADD COLUMN status VARCHAR(200);"}}`))
}
}))
defer server.Close()

host, port := parseTestServerAddr(t, server.URL)

var stdout bytes.Buffer
opts := ReloadOptions{
SkipBuild: true,
Host: host,
Port: port,
Token: "testpass",
Direct: true,
Stdout: &stdout,
}

err := Reload(opts)
if err != nil {
t.Fatalf("Reload: %v", err)
}

output := stdout.String()
if !strings.Contains(output, "WARNING: Database schema changes detected") {
t.Errorf("expected DDL warning in output, got: %s", output)
}
if !strings.Contains(output, "CREATE TABLE") {
t.Errorf("expected DDL commands in output, got: %s", output)
}
if !strings.Contains(output, "docker up --fresh") {
t.Errorf("expected fix suggestion in output, got: %s", output)
}
}

func TestReload_ModelOnly_ReloadError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand Down