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..9bd1f231 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -38,7 +38,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 +53,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 +106,6 @@ The compose file (compose.yaml) must be in the current working directory, as thi }, } -func getComposeFileName() (string, error) { - candidates := []string{"compose.yaml", "compose.yml"} - for _, fileName := range candidates { - if _, err := os.Stat(fileName); err == nil { - return fileName, nil - } - } - return "", fmt.Errorf("compose file not found in current working directory: looking for compose.yaml or compose.yml") -} - func validatePort(port string) error { portNum, err := strconv.Atoi(port) if err != nil { @@ -141,6 +131,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..59723988 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" @@ -35,6 +36,34 @@ func init() { ) } +const composeFileFlag = "file" + +func addComposeFileFlag(cmd *cobra.Command) { + cmd.Flags().StringP( + composeFileFlag, + "f", + "", + "compose file to use (default: compose.yaml, then compose.yml)", + ) +} + +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/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") 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") + }) +}