From 5bb377700641b8c3145335f0257b70b18451e9d4 Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Tue, 7 Jul 2026 13:30:30 +0100 Subject: [PATCH 1/3] Add support for specifying compose files w/ `-f` Signed-off-by: Bartek Mucha --- cmd/topo/configure.go | 5 +++-- cmd/topo/deploy.go | 46 ++++++++++++++++++++++++++++++++++++++----- cmd/topo/ps.go | 5 +++-- cmd/topo/root.go | 11 +++++++++++ cmd/topo/stop.go | 5 +++-- e2e/configure_test.go | 20 +++++++++++++++++++ 6 files changed, 81 insertions(+), 11 deletions(-) diff --git a/cmd/topo/configure.go b/cmd/topo/configure.go index 8b79cfe7..e795df80 100644 --- a/cmd/topo/configure.go +++ b/cmd/topo/configure.go @@ -14,7 +14,7 @@ var configureCmd = &cobra.Command{ Short: "Configure project parameters", Long: `Configure project parameters for the Topo project in the current directory. -The compose file (compose.yaml or compose.yml) must be in the current working directory. +By default, Topo uses compose.yaml in the current working directory, then compose.yml. Use -f to specify a different compose file. Some projects require parameters. Supply them on the command line or answer interactive prompts.`, @@ -27,7 +27,7 @@ interactive prompts.`, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - composeFile, err := getComposeFileName() + composeFile, err := getComposeFileName(cmd) if err != nil { return err } @@ -51,5 +51,6 @@ interactive prompts.`, } func init() { + addComposeFileFlag(configureCmd) rootCmd.AddCommand(configureCmd) } diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index 237502dd..44a40d3d 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "runtime" @@ -38,7 +39,7 @@ This command performs the following operations in sequence: 3. Transfer - Transfers built and pulled images and compose file to the target 4. Run - Runs docker compose up on the target -The compose file (compose.yaml) must be in the current working directory, as this is used to select the containers to be deployed.`, +By default, Topo uses compose.yaml in the current working directory, then compose.yml. Use -f to specify a different compose file.`, Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true @@ -53,7 +54,7 @@ The compose file (compose.yaml) must be in the current working directory, as thi return err } - composeFile, err := getComposeFileName() + composeFile, err := getComposeFileName(cmd) if err != nil { return err } @@ -106,16 +107,50 @@ The compose file (compose.yaml) must be in the current working directory, as thi }, } -func getComposeFileName() (string, error) { +var errComposeFileNotFound = errors.New("compose file not found") + +func getComposeFileName(cmd *cobra.Command) (string, error) { + flag := cmd.Flag(composeFileFlag) + if flag == nil { + panic(fmt.Sprintf("internal error: compose file flag not registered: %s", composeFileFlag)) + } + + if flag.Changed { + composeFile := strings.TrimSpace(flag.Value.String()) + if composeFile == "" { + return "", fmt.Errorf("compose file path must not be empty") + } + return validateComposeFile(composeFile) + } + candidates := []string{"compose.yaml", "compose.yml"} for _, fileName := range candidates { - if _, err := os.Stat(fileName); err == nil { - return fileName, nil + composeFile, err := validateComposeFile(fileName) + if err == nil { + return composeFile, nil + } + if errors.Is(err, errComposeFileNotFound) { + continue } + return "", err } return "", fmt.Errorf("compose file not found in current working directory: looking for compose.yaml or compose.yml") } +func validateComposeFile(composeFile string) (string, error) { + info, err := os.Stat(composeFile) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("%w: %s", errComposeFileNotFound, composeFile) + } + return "", fmt.Errorf("failed to access compose file %s: %w", composeFile, err) + } + if info.IsDir() { + return "", fmt.Errorf("compose file path is a directory: %s", composeFile) + } + return composeFile, nil +} + func validatePort(port string) error { portNum, err := strconv.Atoi(port) if err != nil { @@ -141,6 +176,7 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { func init() { addTargetFlag(deployCmd) + addComposeFileFlag(deployCmd) deployCmd.Flags().StringVarP(®istryPort, "registry-port", "p", operation.DefaultRegistryPort, fmt.Sprintf("registry and SSH tunnel port (can also be set via %s env var)", portEnvVar)) deployCmd.Flags().BoolVar(&noRegistry, "no-registry", false, "disable private registry flow; use direct save/load transfer") deployCmd.Flags().BoolVar(&forceRecreate, "force-recreate", false, "force recreation of containers even if their configuration and image haven't changed") diff --git a/cmd/topo/ps.go b/cmd/topo/ps.go index bb2b3fc8..d3fa2c47 100644 --- a/cmd/topo/ps.go +++ b/cmd/topo/ps.go @@ -15,7 +15,7 @@ var topoPsCmd = &cobra.Command{ Short: "List containers on the target for the current Compose project.", Long: `List containers on the target for the current Compose project. -The compose.yaml must be in the current working directory, as this is used to select containers to be viewed. +By default, Topo uses compose.yaml in the current working directory, then compose.yml. Use -f to specify a different compose file. `, Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, _ []string) error { @@ -27,7 +27,7 @@ The compose.yaml must be in the current working directory, as this is used to se return err } - composeFile, err := getComposeFileName() + composeFile, err := getComposeFileName(cmd) if err != nil { return err } @@ -51,6 +51,7 @@ The compose.yaml must be in the current working directory, as this is used to se func init() { addTargetFlag(topoPsCmd) + addComposeFileFlag(topoPsCmd) topoPsCmd.Flags().BoolP("all", "a", false, "show all containers, including stopped") rootCmd.AddCommand(topoPsCmd) } diff --git a/cmd/topo/root.go b/cmd/topo/root.go index f2cda843..d96957da 100644 --- a/cmd/topo/root.go +++ b/cmd/topo/root.go @@ -26,6 +26,8 @@ var rootCmd = &cobra.Command{ }, } +const composeFileFlag = "file" + func init() { rootCmd.PersistentFlags().StringP( "output", @@ -35,6 +37,15 @@ func init() { ) } +func addComposeFileFlag(cmd *cobra.Command) { + cmd.Flags().StringP( + composeFileFlag, + "f", + "", + "compose file to use (default: compose.yaml, then compose.yml)", + ) +} + const targetEnvVar = "TOPO_TARGET" func addTargetFlag(cmd *cobra.Command) { diff --git a/cmd/topo/stop.go b/cmd/topo/stop.go index 72aec300..5ad4ca24 100644 --- a/cmd/topo/stop.go +++ b/cmd/topo/stop.go @@ -16,7 +16,7 @@ var topoStopCmd = &cobra.Command{ Executing this command does not remove the containers. -The compose file (compose.yaml) must be in the current working directory, as this is used to select the containers to be stopped.`, +By default, Topo uses compose.yaml in the current working directory, then compose.yml. Use -f to specify a different compose file.`, Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true @@ -26,7 +26,7 @@ The compose file (compose.yaml) must be in the current working directory, as thi return err } - composeFile, err := getComposeFileName() + composeFile, err := getComposeFileName(cmd) if err != nil { return err } @@ -41,5 +41,6 @@ The compose file (compose.yaml) must be in the current working directory, as thi func init() { addTargetFlag(topoStopCmd) + addComposeFileFlag(topoStopCmd) rootCmd.AddCommand(topoStopCmd) } diff --git a/e2e/configure_test.go b/e2e/configure_test.go index a7f2d5dc..14e38a24 100644 --- a/e2e/configure_test.go +++ b/e2e/configure_test.go @@ -45,6 +45,26 @@ func TestConfigure(t *testing.T) { assert.YAMLEq(t, want, got) }) + t.Run("respect compose file flag", func(t *testing.T) { + projectDir := t.TempDir() + composePath := filepath.Join(projectDir, "compose.yaml") + customComposePath := filepath.Join(projectDir, "custom-compose.yaml") + originalCompose := configurableCompose("Original") + testutil.RequireWriteFile(t, composePath, originalCompose) + testutil.RequireWriteFile(t, customComposePath, configurableCompose("CustomOriginal")) + + cmd := exec.Command(topo, "configure", "-f", "custom-compose.yaml", "GREETING_NAME=Custom") + cmd.Dir = projectDir + out, err := cmd.CombinedOutput() + + require.NoErrorf(t, err, "configure failed: %s", out) + assert.Empty(t, string(out)) + want := configurableCompose("Custom") + got := testutil.RequireReadFile(t, customComposePath) + assert.YAMLEq(t, want, got) + assert.Equal(t, originalCompose, testutil.RequireReadFile(t, composePath)) + }) + t.Run("rejects undeclared parameters without changing the compose file", func(t *testing.T) { projectDir := t.TempDir() composePath := filepath.Join(projectDir, "compose.yaml") From cf73ecc11682d0071eee0c16aa53072a5a638d48 Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Tue, 7 Jul 2026 14:02:33 +0100 Subject: [PATCH 2/3] Refactor and test Signed-off-by: Bartek Mucha --- cmd/topo/deploy.go | 45 ---------------- cmd/topo/root.go | 18 +++++++ internal/compose/file.go | 73 ++++++++++++++++++++++++++ internal/compose/file_test.go | 99 +++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 45 deletions(-) create mode 100644 internal/compose/file.go create mode 100644 internal/compose/file_test.go diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index 44a40d3d..9bd1f231 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -1,7 +1,6 @@ package main import ( - "errors" "fmt" "os" "runtime" @@ -107,50 +106,6 @@ By default, Topo uses compose.yaml in the current working directory, then compos }, } -var errComposeFileNotFound = errors.New("compose file not found") - -func getComposeFileName(cmd *cobra.Command) (string, error) { - flag := cmd.Flag(composeFileFlag) - if flag == nil { - panic(fmt.Sprintf("internal error: compose file flag not registered: %s", composeFileFlag)) - } - - if flag.Changed { - composeFile := strings.TrimSpace(flag.Value.String()) - if composeFile == "" { - return "", fmt.Errorf("compose file path must not be empty") - } - return validateComposeFile(composeFile) - } - - candidates := []string{"compose.yaml", "compose.yml"} - for _, fileName := range candidates { - composeFile, err := validateComposeFile(fileName) - if err == nil { - return composeFile, nil - } - if errors.Is(err, errComposeFileNotFound) { - continue - } - return "", err - } - return "", fmt.Errorf("compose file not found in current working directory: looking for compose.yaml or compose.yml") -} - -func validateComposeFile(composeFile string) (string, error) { - info, err := os.Stat(composeFile) - if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("%w: %s", errComposeFileNotFound, composeFile) - } - return "", fmt.Errorf("failed to access compose file %s: %w", composeFile, err) - } - if info.IsDir() { - return "", fmt.Errorf("compose file path is a directory: %s", composeFile) - } - return composeFile, nil -} - func validatePort(port string) error { portNum, err := strconv.Atoi(port) if err != nil { diff --git a/cmd/topo/root.go b/cmd/topo/root.go index d96957da..588ddbf8 100644 --- a/cmd/topo/root.go +++ b/cmd/topo/root.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/arm/topo/internal/compose" "github.com/arm/topo/internal/env" "github.com/arm/topo/internal/output/logger" "github.com/arm/topo/internal/output/term" @@ -46,6 +47,23 @@ func addComposeFileFlag(cmd *cobra.Command) { ) } +func getComposeFileName(cmd *cobra.Command) (string, error) { + flag := cmd.Flag(composeFileFlag) + if flag == nil { + panic(fmt.Sprintf("internal error: compose file flag not registered: %s", composeFileFlag)) + } + + if flag.Changed { + composeFile := strings.TrimSpace(flag.Value.String()) + if composeFile == "" { + return "", fmt.Errorf("compose file path must not be empty") + } + return compose.RequireFile(composeFile) + } + + return compose.FindDefaultFile() +} + const targetEnvVar = "TOPO_TARGET" func addTargetFlag(cmd *cobra.Command) { diff --git a/internal/compose/file.go b/internal/compose/file.go new file mode 100644 index 00000000..b9dd8322 --- /dev/null +++ b/internal/compose/file.go @@ -0,0 +1,73 @@ +package compose + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/arm/topo/internal/output/logger" +) + +var errFileNotFound = errors.New("compose file not found") + +var defaultFileNames = []string{"compose.yaml", "compose.yml"} + +// RequireFile returns the path when it exists and is not a directory. +func RequireFile(composeFile string) (string, error) { + info, err := os.Stat(composeFile) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("%w: %s", errFileNotFound, composeFile) + } + return "", fmt.Errorf("failed to access compose file %s: %w", composeFile, err) + } + if info.IsDir() { + return "", fmt.Errorf("compose file path is a directory: %s", composeFile) + } + return composeFile, nil +} + +// FindDefaultFile returns the first existing default compose file. +func FindDefaultFile() (string, error) { + type defaultFileCandidate struct { + name string + info os.FileInfo + } + candidates := []defaultFileCandidate{} + + for _, fileName := range defaultFileNames { + info, err := os.Stat(fileName) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", fmt.Errorf("failed to access compose file %s: %w", fileName, err) + } + + candidates = append(candidates, defaultFileCandidate{ + name: fileName, + info: info, + }) + } + + if len(candidates) == 0 { + return "", fmt.Errorf( + "compose file not found in current working directory: looking for %s", + strings.Join(defaultFileNames, " or "), + ) + } + + defaultCandidate := candidates[0] + if len(candidates) > 1 { + logger.Warn(fmt.Sprintf( + "found multiple compose files: %s; using %s", + strings.Join(defaultFileNames, ", "), + defaultCandidate.name, + )) + } + if defaultCandidate.info.IsDir() { + return "", fmt.Errorf("compose file path is a directory: %s", defaultCandidate.name) + } + return defaultCandidate.name, nil +} diff --git a/internal/compose/file_test.go b/internal/compose/file_test.go new file mode 100644 index 00000000..852851d2 --- /dev/null +++ b/internal/compose/file_test.go @@ -0,0 +1,99 @@ +package compose_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/arm/topo/internal/compose" + "github.com/arm/topo/internal/output/logger" + "github.com/arm/topo/internal/output/term" + "github.com/arm/topo/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequireFile(t *testing.T) { + t.Run("returns path when compose file exists", func(t *testing.T) { + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + testutil.RequireWriteFile(t, composeFile, "") + + got, err := compose.RequireFile(composeFile) + + require.NoError(t, err) + assert.Equal(t, composeFile, got) + }) + + t.Run("returns error when compose file does not exist", func(t *testing.T) { + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + + got, err := compose.RequireFile(composeFile) + + require.Error(t, err) + assert.Empty(t, got) + assert.ErrorContains(t, err, "compose file not found") + assert.ErrorContains(t, err, composeFile) + }) + + t.Run("returns error when compose file path is a directory", func(t *testing.T) { + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + require.NoError(t, os.Mkdir(composeFile, 0o755)) + + got, err := compose.RequireFile(composeFile) + + require.Error(t, err) + assert.Empty(t, got) + assert.ErrorContains(t, err, "compose file path is a directory") + assert.ErrorContains(t, err, composeFile) + }) +} + +func TestFindDefaultFile(t *testing.T) { + t.Run("returns compose.yaml when it exists", func(t *testing.T) { + t.Chdir(t.TempDir()) + testutil.RequireWriteFile(t, "compose.yaml", "") + + got, err := compose.FindDefaultFile() + + require.NoError(t, err) + assert.Equal(t, "compose.yaml", got) + }) + + t.Run("falls back to compose.yml", func(t *testing.T) { + t.Chdir(t.TempDir()) + testutil.RequireWriteFile(t, "compose.yml", "") + + got, err := compose.FindDefaultFile() + + require.NoError(t, err) + assert.Equal(t, "compose.yml", got) + }) + + t.Run("warns when multiple default compose files exist", func(t *testing.T) { + t.Chdir(t.TempDir()) + testutil.RequireWriteFile(t, "compose.yaml", "") + testutil.RequireWriteFile(t, "compose.yml", "") + var logOutput bytes.Buffer + logger.SetOptions(logger.Options{Output: &logOutput, Format: term.Plain}) + t.Cleanup(func() { + logger.SetOptions(logger.Options{}) + }) + + got, err := compose.FindDefaultFile() + + require.NoError(t, err) + assert.Equal(t, "compose.yaml", got) + assert.Contains(t, logOutput.String(), "found multiple compose files: compose.yaml, compose.yml; using compose.yaml") + }) + + t.Run("returns error when no default compose file exists", func(t *testing.T) { + t.Chdir(t.TempDir()) + + got, err := compose.FindDefaultFile() + + require.Error(t, err) + assert.Empty(t, got) + assert.EqualError(t, err, "compose file not found in current working directory: looking for compose.yaml or compose.yml") + }) +} From 183f4526b3ec63c82ca2c3514f8d4760c341a2a5 Mon Sep 17 00:00:00 2001 From: Bartek Mucha Date: Tue, 7 Jul 2026 14:50:20 +0100 Subject: [PATCH 3/3] Move it closer Signed-off-by: Bartek Mucha --- cmd/topo/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/topo/root.go b/cmd/topo/root.go index 588ddbf8..59723988 100644 --- a/cmd/topo/root.go +++ b/cmd/topo/root.go @@ -27,8 +27,6 @@ var rootCmd = &cobra.Command{ }, } -const composeFileFlag = "file" - func init() { rootCmd.PersistentFlags().StringP( "output", @@ -38,6 +36,8 @@ func init() { ) } +const composeFileFlag = "file" + func addComposeFileFlag(cmd *cobra.Command) { cmd.Flags().StringP( composeFileFlag,