From b15bae4b4df4bad940bd62691864f77f150c62b3 Mon Sep 17 00:00:00 2001 From: awphi <26072111+awphi@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:50:24 +0100 Subject: [PATCH 1/2] feat: support compose-style interpolation in deployment_success_message Signed-off-by: awphi <26072111+awphi@users.noreply.github.com> --- .../01-authoring-projects.md | 14 +++++++++ .../schema/topo-project-specification.json | 2 +- internal/compose/compose.go | 9 ++++++ internal/deploy/docker/deploy.go | 2 +- internal/deploy/post_deploy/post_deploy.go | 22 +++++++------- .../deploy/post_deploy/post_deploy_test.go | 27 +++++++++++++++-- internal/env/compose.go | 19 ++++++++++++ internal/env/compose_test.go | 21 ++++++++++++++ internal/project/definition.go | 21 ++++++-------- internal/project/definition_test.go | 29 ------------------- 10 files changed, 110 insertions(+), 56 deletions(-) create mode 100644 internal/env/compose.go create mode 100644 internal/env/compose_test.go diff --git a/docs/project-specification/01-authoring-projects.md b/docs/project-specification/01-authoring-projects.md index bf32acfb..b94ed802 100644 --- a/docs/project-specification/01-authoring-projects.md +++ b/docs/project-specification/01-authoring-projects.md @@ -75,6 +75,20 @@ x-topo: example: string # Optional ``` +### Deployment success message + +`deployment_success_message` supports standard Compose interpolation. Topo provides these additional variables when it displays the message: + +- `TOPO_TARGET`: The complete SSH destination in URI form, such as `ssh://user@board.local`. +- `TOPO_TARGET_HOSTNAME`: The Target hostname resolved from the SSH configuration, such as `board.local`. + +```yaml +x-topo: + name: "web-app" + deployment_success_message: | + Open http://${TOPO_TARGET_HOSTNAME:-localhost}:8080 +``` + --- ## 4. Testing Your Project diff --git a/docs/project-specification/schema/topo-project-specification.json b/docs/project-specification/schema/topo-project-specification.json index 4bd3279a..9015ca39 100644 --- a/docs/project-specification/schema/topo-project-specification.json +++ b/docs/project-specification/schema/topo-project-specification.json @@ -29,7 +29,7 @@ }, "deployment_success_message": { "type": "string", - "description": "Message displayed to the user after a successful deployment. If omitted, a default message is shown." + "description": "Message displayed after a successful deployment. Supports Compose interpolation. Topo provides TOPO_TARGET and TOPO_TARGET_HOSTNAME when rendering this message. If omitted, a default message is shown." }, "parameters": { "type": "object", diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 31e87624..76199aa5 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -42,12 +42,21 @@ func PullableServices(composeFilePath string) ([]string, error) { } func ReadProject(targetProjectFile string) (*types.Project, error) { + return readProject(targetProjectFile, nil) +} + +func ReadProjectWithEnvironment(targetProjectFile string, environment []string) (*types.Project, error) { + return readProject(targetProjectFile, environment) +} + +func readProject(targetProjectFile string, environment []string) (*types.Project, error) { ctx := context.Background() options, err := cli.NewProjectOptions( []string{targetProjectFile}, cli.WithResolvedPaths(false), cli.WithNormalization(false), cli.WithEnvFiles(), + cli.WithEnv(environment), ) if err != nil { return nil, err diff --git a/internal/deploy/docker/deploy.go b/internal/deploy/docker/deploy.go index 93988ade..b6db3163 100644 --- a/internal/deploy/docker/deploy.go +++ b/internal/deploy/docker/deploy.go @@ -79,7 +79,7 @@ func Deploy(ctx context.Context, output io.Writer, composeFile string, opts Depl if err := term.PrintHeader(output, "Deployment Success"); err != nil { return err } - return post_deploy.PrintDeploySuccess(output, composeFile, post_deploy.DefaultMessage(composeFile)) + return post_deploy.PrintDeploySuccess(output, composeFile, opts.TargetHost, post_deploy.DefaultMessage(composeFile)) } func transferImagesViaPipe(ctx context.Context, output io.Writer, sourceHost, targetHost Host, composeFile string) error { diff --git a/internal/deploy/post_deploy/post_deploy.go b/internal/deploy/post_deploy/post_deploy.go index b9ce0d5a..70c3504a 100644 --- a/internal/deploy/post_deploy/post_deploy.go +++ b/internal/deploy/post_deploy/post_deploy.go @@ -3,11 +3,11 @@ package post_deploy import ( "fmt" "io" - "os" cmdtext "github.com/arm/topo/internal/command" "github.com/arm/topo/internal/compose" - "github.com/arm/topo/internal/project" + "github.com/arm/topo/internal/env" + "github.com/arm/topo/internal/ssh" ) func DefaultMessage(composeFile string) string { @@ -18,22 +18,24 @@ func DefaultMessage(composeFile string) string { return fmt.Sprintf("Run `topo ps -f %s` to see deployed containers", cmdtext.QuoteArg(composeFile)) } -func getSuccessMessage(composeFile string) (string, error) { - f, err := os.Open(composeFile) +func getSuccessMessage(composeFile string, target ssh.Destination) (string, error) { + composeProject, err := compose.ReadProjectWithEnvironment(composeFile, env.ComposeEnv(target)) if err != nil { return "", err } - defer func() { _ = f.Close() }() - p, err := project.FromContent(f) - if err != nil { + var metadata struct { + DeploymentSuccessMessage string `mapstructure:"deployment_success_message"` + } + found, err := composeProject.Extensions.Get("x-topo", &metadata) + if err != nil || !found { return "", err } - return p.Metadata.DeploymentSuccessMessage, nil + return metadata.DeploymentSuccessMessage, nil } -func PrintDeploySuccess(output io.Writer, composeFile, defaultMessage string) error { - successMessage, err := getSuccessMessage(composeFile) +func PrintDeploySuccess(output io.Writer, composeFile string, target ssh.Destination, defaultMessage string) error { + successMessage, err := getSuccessMessage(composeFile, target) if err != nil { return err } diff --git a/internal/deploy/post_deploy/post_deploy_test.go b/internal/deploy/post_deploy/post_deploy_test.go index 6b9d7d07..ae97ad14 100644 --- a/internal/deploy/post_deploy/post_deploy_test.go +++ b/internal/deploy/post_deploy/post_deploy_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/arm/topo/internal/deploy/post_deploy" + "github.com/arm/topo/internal/ssh" "github.com/arm/topo/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +25,7 @@ services: `) var buf bytes.Buffer - err := post_deploy.PrintDeploySuccess(&buf, composeFile, "Run `topo ps` to see deployed containers") + err := post_deploy.PrintDeploySuccess(&buf, composeFile, ssh.PlainLocalhost, "Run `topo ps` to see deployed containers") require.NoError(t, err) assert.Equal(t, "Deployment complete!\n", buf.String()) @@ -40,16 +41,36 @@ services: `) var buf bytes.Buffer - err := post_deploy.PrintDeploySuccess(&buf, composeFile, "default message") + err := post_deploy.PrintDeploySuccess(&buf, composeFile, ssh.PlainLocalhost, "default message") require.NoError(t, err) assert.Equal(t, "default message\n", buf.String()) }) + t.Run("interpolates the target in deployment_success_message", func(t *testing.T) { + dir := t.TempDir() + composeFile := filepath.Join(dir, "compose.yaml") + testutil.RequireWriteFile(t, composeFile, ` +name: test-project +x-topo: + deployment_success_message: "${COMPOSE_PROJECT_NAME} deployed to ${TOPO_TARGET} at ${TOPO_TARGET_HOSTNAME}" +services: + app: + image: nginx +`) + var buf bytes.Buffer + target := ssh.NewDestination("user@localhost") + + err := post_deploy.PrintDeploySuccess(&buf, composeFile, target, "default message") + + require.NoError(t, err) + assert.Equal(t, "test-project deployed to ssh://user@localhost at localhost\n", buf.String()) + }) + t.Run("returns error when compose file does not exist", func(t *testing.T) { var buf bytes.Buffer - err := post_deploy.PrintDeploySuccess(&buf, "nonexistent.yaml", "Run `topo ps` to see deployed containers") + err := post_deploy.PrintDeploySuccess(&buf, "nonexistent.yaml", ssh.PlainLocalhost, "Run `topo ps` to see deployed containers") require.Error(t, err) }) diff --git a/internal/env/compose.go b/internal/env/compose.go new file mode 100644 index 00000000..4c0982a5 --- /dev/null +++ b/internal/env/compose.go @@ -0,0 +1,19 @@ +package env + +import "github.com/arm/topo/internal/ssh" + +const ( + target = "TOPO_TARGET" + hostName = "TOPO_TARGET_HOSTNAME" +) + +func variable(name, value string) string { + return name + "=" + value +} + +func ComposeEnv(targetDestination ssh.Destination) []string { + return []string{ + variable(target, targetDestination.String()), + variable(hostName, ssh.NewConfig(targetDestination).HostName), + } +} diff --git a/internal/env/compose_test.go b/internal/env/compose_test.go new file mode 100644 index 00000000..da56cd09 --- /dev/null +++ b/internal/env/compose_test.go @@ -0,0 +1,21 @@ +package env_test + +import ( + "testing" + + "github.com/arm/topo/internal/env" + "github.com/arm/topo/internal/ssh" + "github.com/stretchr/testify/assert" +) + +func TestComposeEnv(t *testing.T) { + target := ssh.NewDestination("user@localhost") + + got := env.ComposeEnv(target) + + want := []string{ + "TOPO_TARGET=ssh://user@localhost", + "TOPO_TARGET_HOSTNAME=localhost", + } + assert.Equal(t, want, got) +} diff --git a/internal/project/definition.go b/internal/project/definition.go index abcc49bb..8392c999 100644 --- a/internal/project/definition.go +++ b/internal/project/definition.go @@ -15,11 +15,10 @@ type Project struct { } type Metadata struct { - Name string - Description string - DeploymentSuccessMessage string - Features []string - Parameters []Parameter + Name string + Description string + Features []string + Parameters []Parameter } type Parameter struct { @@ -47,12 +46,11 @@ func FromContent(reader io.Reader) (Project, error) { } type rawMetadata struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - DeploymentSuccessMessage string `yaml:"deployment_success_message"` - Features []string `yaml:"features,omitempty"` - Parameters map[string]rawParameter `yaml:"parameters,omitempty"` - Args map[string]rawParameter `yaml:"args,omitempty"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Features []string `yaml:"features,omitempty"` + Parameters map[string]rawParameter `yaml:"parameters,omitempty"` + Args map[string]rawParameter `yaml:"args,omitempty"` } type rawParameter struct { @@ -70,7 +68,6 @@ func (t *Metadata) UnmarshalYAML(node *yaml.Node) error { t.Name = raw.Name t.Description = raw.Description - t.DeploymentSuccessMessage = raw.DeploymentSuccessMessage t.Features = raw.Features parametersNode := findMetadataNode(node, "parameters") parameters := raw.Parameters diff --git a/internal/project/definition_test.go b/internal/project/definition_test.go index 6214f5df..b62686b0 100644 --- a/internal/project/definition_test.go +++ b/internal/project/definition_test.go @@ -144,33 +144,4 @@ func TestFromContent(t *testing.T) { } assert.Equal(t, want, got) }) - - t.Run("parses deployment_success_message from x-topo metadata", func(t *testing.T) { - composeFileContents := ` -x-topo: - name: "test-service" - deployment_success_message: "Deployment complete!" -` - p, err := project.FromContent(strings.NewReader(composeFileContents)) - got := p.Metadata - - require.NoError(t, err) - want := project.Metadata{ - Name: "test-service", - DeploymentSuccessMessage: "Deployment complete!", - } - assert.Equal(t, want, got) - }) - - t.Run("leaves DeploymentSuccessMessage empty when deployment_success_message absent from x-topo", func(t *testing.T) { - composeFileContents := ` -x-topo: - name: "test-service" -` - p, err := project.FromContent(strings.NewReader(composeFileContents)) - got := p.Metadata - - require.NoError(t, err) - assert.Empty(t, got.DeploymentSuccessMessage) - }) } From b943e776769f49d268311dd70b67d14c4ef1208e Mon Sep 17 00:00:00 2001 From: awphi <26072111+awphi@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:05:10 +0100 Subject: [PATCH 2/2] env > environment Signed-off-by: awphi <26072111+awphi@users.noreply.github.com> --- internal/compose/compose.go | 8 ++++---- internal/deploy/post_deploy/post_deploy.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 76199aa5..9ccdc0c4 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -45,18 +45,18 @@ func ReadProject(targetProjectFile string) (*types.Project, error) { return readProject(targetProjectFile, nil) } -func ReadProjectWithEnvironment(targetProjectFile string, environment []string) (*types.Project, error) { - return readProject(targetProjectFile, environment) +func ReadProjectWithEnv(targetProjectFile string, env []string) (*types.Project, error) { + return readProject(targetProjectFile, env) } -func readProject(targetProjectFile string, environment []string) (*types.Project, error) { +func readProject(targetProjectFile string, env []string) (*types.Project, error) { ctx := context.Background() options, err := cli.NewProjectOptions( []string{targetProjectFile}, cli.WithResolvedPaths(false), cli.WithNormalization(false), cli.WithEnvFiles(), - cli.WithEnv(environment), + cli.WithEnv(env), ) if err != nil { return nil, err diff --git a/internal/deploy/post_deploy/post_deploy.go b/internal/deploy/post_deploy/post_deploy.go index 70c3504a..b60b6392 100644 --- a/internal/deploy/post_deploy/post_deploy.go +++ b/internal/deploy/post_deploy/post_deploy.go @@ -19,7 +19,7 @@ func DefaultMessage(composeFile string) string { } func getSuccessMessage(composeFile string, target ssh.Destination) (string, error) { - composeProject, err := compose.ReadProjectWithEnvironment(composeFile, env.ComposeEnv(target)) + composeProject, err := compose.ReadProjectWithEnv(composeFile, env.ComposeEnv(target)) if err != nil { return "", err }