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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,31 @@ deployment, logs, err := platform.Deploy.
Execute("my-app", "/path/to/my/app/source")
```

### Retrieving runtime logs: `RuntimeLogs`

The `deployment.RuntimeLogs()` method retrieves logs from the running application
after deployment succeeds. This is useful for testing runtime behavior such as
application startup, service connections, and module loading.

```go
// Deploy an application
deployment, stagingLogs, err := platform.Deploy.Execute("my-app", "/path/to/my/app/source")
Expect(err).NotTo(HaveOccurred())

// stagingLogs contains build-time output (buildpack detection, compilation, etc.)
Expect(stagingLogs).To(ContainLines(ContainSubstring("Installing dependencies...")))

// Retrieve runtime logs (application startup, service connections, etc.)
runtimeLogs, err := deployment.RuntimeLogs()
Expect(err).NotTo(HaveOccurred())
Expect(runtimeLogs).To(ContainSubstring("Application started"))
Expect(runtimeLogs).To(ContainSubstring("Connected to Redis"))
```

**Note:** The logs returned from `platform.Deploy.Execute()` are **staging logs**
(build-time), while `deployment.RuntimeLogs()` returns **runtime logs** (post-deployment).
Use staging logs to test buildpack behavior, and runtime logs to test application behavior.

## Other utilities

### Random name generation: `RandomName`
Expand Down
8 changes: 6 additions & 2 deletions cloudfoundry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import (
//go:generate faux --package github.com/cloudfoundry/switchblade/internal/cloudfoundry --interface StagePhase --name CloudFoundryStagePhase --output fakes/cloudfoundry_stage_phase.go
//go:generate faux --package github.com/cloudfoundry/switchblade/internal/cloudfoundry --interface TeardownPhase --name CloudFoundryTeardownPhase --output fakes/cloudfoundry_teardown_phase.go

func NewCloudFoundry(initialize cloudfoundry.InitializePhase, deinitialize cloudfoundry.DeinitializePhase, setup cloudfoundry.SetupPhase, stage cloudfoundry.StagePhase, teardown cloudfoundry.TeardownPhase, workspace string) Platform {
func NewCloudFoundry(initialize cloudfoundry.InitializePhase, deinitialize cloudfoundry.DeinitializePhase, setup cloudfoundry.SetupPhase, stage cloudfoundry.StagePhase, teardown cloudfoundry.TeardownPhase, workspace string, cli cloudfoundry.Executable) Platform {
return Platform{
initialize: cloudFoundryInitializeProcess{initialize: initialize},
deinitialize: cloudFoundryDeinitializeProcess{deinitialize: deinitialize},
Deploy: cloudFoundryDeployProcess{setup: setup, stage: stage, workspace: workspace},
Deploy: cloudFoundryDeployProcess{setup: setup, stage: stage, workspace: workspace, cli: cli},
Delete: cloudFoundryDeleteProcess{teardown: teardown, workspace: workspace},
}
}
Expand Down Expand Up @@ -51,6 +51,7 @@ type cloudFoundryDeployProcess struct {
setup cloudfoundry.SetupPhase
stage cloudfoundry.StagePhase
workspace string
cli cloudfoundry.Executable
}

func (p cloudFoundryDeployProcess) WithBuildpacks(buildpacks ...string) DeployProcess {
Expand Down Expand Up @@ -111,6 +112,9 @@ func (p cloudFoundryDeployProcess) Execute(name, source string) (Deployment, fmt
Name: name,
ExternalURL: externalURL,
InternalURL: internalURL,
platform: CloudFoundry,
workspace: home,
cfCLI: p.cli,
}, logs, nil
}

Expand Down
50 changes: 44 additions & 6 deletions cloudfoundry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/cloudfoundry/switchblade"
"github.com/cloudfoundry/switchblade/fakes"
"github.com/cloudfoundry/switchblade/internal/cloudfoundry"
cffakes "github.com/cloudfoundry/switchblade/internal/cloudfoundry/fakes"
"github.com/paketo-buildpacks/packit/v2/pexec"
"github.com/sclevine/spec"

. "github.com/cloudfoundry/switchblade/matchers"
Expand All @@ -26,6 +28,7 @@ func testCloudFoundry(t *testing.T, context spec.G, it spec.S) {
setup *fakes.CloudFoundrySetupPhase
stage *fakes.CloudFoundryStagePhase
teardown *fakes.CloudFoundryTeardownPhase
cli *cffakes.Executable
workspace string

platform switchblade.Platform
Expand All @@ -37,12 +40,13 @@ func testCloudFoundry(t *testing.T, context spec.G, it spec.S) {
setup = &fakes.CloudFoundrySetupPhase{}
stage = &fakes.CloudFoundryStagePhase{}
teardown = &fakes.CloudFoundryTeardownPhase{}
cli = &cffakes.Executable{}

var err error
workspace, err = os.MkdirTemp("", "workspace")
Expect(err).NotTo(HaveOccurred())

platform = switchblade.NewCloudFoundry(initialize, deinitialize, setup, stage, teardown, workspace)
platform = switchblade.NewCloudFoundry(initialize, deinitialize, setup, stage, teardown, workspace, cli)
})

it.After(func() {
Expand Down Expand Up @@ -142,11 +146,9 @@ func testCloudFoundry(t *testing.T, context spec.G, it spec.S) {
it("executes the setup and stage phases", func() {
deployment, logs, err := platform.Deploy.Execute("some-app", "/some/path/to/my/app")
Expect(err).NotTo(HaveOccurred())
Expect(deployment).To(Equal(switchblade.Deployment{
Name: "some-app",
ExternalURL: "some-external-url",
InternalURL: "some-internal-url",
}))
Expect(deployment.Name).To(Equal("some-app"))
Expect(deployment.ExternalURL).To(Equal("some-external-url"))
Expect(deployment.InternalURL).To(Equal("some-internal-url"))
Expect(logs).To(ContainLines(
"Setting up...",
"Staging...",
Expand All @@ -162,6 +164,42 @@ func testCloudFoundry(t *testing.T, context spec.G, it spec.S) {
Expect(stage.RunCall.Receives.Name).To(Equal("some-app"))
})

it("retrieves runtime logs from deployed application", func() {
cli.ExecuteCall.Stub = func(execution pexec.Execution) error {
if execution.Args[0] == "logs" {
fmt.Fprintln(execution.Stdout, "Runtime log line 1")
fmt.Fprintln(execution.Stdout, "Application started successfully")
fmt.Fprintln(execution.Stdout, "Runtime log line 3")
}
return nil
}

deployment, stagingLogs, err := platform.Deploy.Execute("some-app", "/some/path/to/my/app")
Expect(err).NotTo(HaveOccurred())

// Staging logs should contain setup/staging output
Expect(stagingLogs.String()).To(ContainSubstring("Setting up..."))
Expect(stagingLogs.String()).To(ContainSubstring("Staging..."))

// Runtime logs should contain application output
runtimeLogs, err := deployment.RuntimeLogs()
Expect(err).NotTo(HaveOccurred())
Expect(runtimeLogs).To(ContainSubstring("Runtime log line 1"))
Expect(runtimeLogs).To(ContainSubstring("Application started successfully"))
Expect(runtimeLogs).To(ContainSubstring("Runtime log line 3"))

// Verify the CLI was called with the correct arguments
var logsCallReceived pexec.Execution
for i := 0; i < cli.ExecuteCall.CallCount; i++ {
if len(cli.ExecuteCall.Receives.Execution.Args) > 0 && cli.ExecuteCall.Receives.Execution.Args[0] == "logs" {
logsCallReceived = cli.ExecuteCall.Receives.Execution
break
}
}
Expect(logsCallReceived.Args).To(Equal([]string{"logs", "some-app", "--recent"}))
Expect(logsCallReceived.Env).To(ContainElement(ContainSubstring("CF_HOME=")))
})

context("WithBuildpacks", func() {
it("uses those buildpacks", func() {
platform.Deploy.WithBuildpacks("some-buildpack", "other-buildpack")
Expand Down
70 changes: 70 additions & 0 deletions deployment.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,77 @@
package switchblade

import (
"context"
"fmt"
"io"

"github.com/cloudfoundry/switchblade/internal/cloudfoundry"
"github.com/docker/docker/api/types/container"
)

//go:generate faux --interface LogsClient --output fakes/logs_client.go
type LogsClient interface {
ContainerLogs(ctx context.Context, container string, options container.LogsOptions) (io.ReadCloser, error)
}

type Deployment struct {
Name string
ExternalURL string
InternalURL string

// Internal fields for log retrieval
platform string
workspace string
cfCLI cloudfoundry.Executable
dockerCLI LogsClient
}

// RuntimeLogs retrieves recent logs from the running application.
// These are logs generated after the application has started (post-staging).
// This method abstracts platform-specific log retrieval for both
// CloudFoundry and Docker platforms.
//
// Use this for testing:
// - Application startup messages
// - Service connections
// - Module/extension loading
// - Runtime configuration
//
// For build-time logs (staging, buildpack detection), use the logs
// returned from platform.Deploy.Execute() instead.
func (d Deployment) RuntimeLogs() (string, error) {
switch d.platform {
case CloudFoundry:
return d.logsCloudFoundry()
case Docker:
return d.logsDocker()
default:
return "", fmt.Errorf("unknown platform type: %q", d.platform)
}
}

func (d Deployment) logsCloudFoundry() (string, error) {
return cloudfoundry.FetchRecentLogs(d.cfCLI, d.workspace, d.Name)
}

func (d Deployment) logsDocker() (string, error) {
ctx := context.Background()

options := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
}

reader, err := d.dockerCLI.ContainerLogs(ctx, d.Name, options)
if err != nil {
return "", fmt.Errorf("failed to retrieve container logs: %w", err)
}
defer reader.Close()

logs, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("failed to read logs: %w", err)
}

return string(logs), nil
}
13 changes: 8 additions & 5 deletions docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import (
//go:generate faux --package github.com/cloudfoundry/switchblade/internal/docker --interface StartPhase --name DockerStartPhase --output fakes/docker_start_phase.go
//go:generate faux --package github.com/cloudfoundry/switchblade/internal/docker --interface TeardownPhase --name DockerTeardownPhase --output fakes/docker_teardown_phase.go

func NewDocker(initialize docker.InitializePhase, deinitialize docker.DeinitializePhase, setup docker.SetupPhase, stage docker.StagePhase, start docker.StartPhase, teardown docker.TeardownPhase) Platform {
func NewDocker(initialize docker.InitializePhase, deinitialize docker.DeinitializePhase, setup docker.SetupPhase, stage docker.StagePhase, start docker.StartPhase, teardown docker.TeardownPhase, client LogsClient) Platform {
return Platform{
initialize: dockerInitializeProcess{initialize: initialize},
deinitialize: dockerDeinitializeProcess{deinitialize: deinitialize},
Deploy: dockerDeployProcess{setup: setup, stage: stage, start: start},
Deploy: dockerDeployProcess{setup: setup, stage: stage, start: start, client: client},
Delete: dockerDeleteProcess{teardown: teardown},
}
}
Expand Down Expand Up @@ -49,9 +49,10 @@ func (p dockerDeinitializeProcess) Execute() error {
}

type dockerDeployProcess struct {
setup docker.SetupPhase
stage docker.StagePhase
start docker.StartPhase
setup docker.SetupPhase
stage docker.StagePhase
start docker.StartPhase
client LogsClient
}

func (p dockerDeployProcess) WithBuildpacks(buildpacks ...string) DeployProcess {
Expand Down Expand Up @@ -120,6 +121,8 @@ func (p dockerDeployProcess) Execute(name, path string) (Deployment, fmt.Stringe
Name: name,
ExternalURL: externalURL,
InternalURL: internalURL,
platform: Docker,
dockerCLI: p.client,
}, logs, nil
}

Expand Down
45 changes: 39 additions & 6 deletions docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"io"
"strings"
"testing"

"github.com/cloudfoundry/switchblade"
"github.com/cloudfoundry/switchblade/fakes"
"github.com/cloudfoundry/switchblade/internal/docker"
"github.com/docker/docker/api/types/container"
"github.com/sclevine/spec"

. "github.com/cloudfoundry/switchblade/matchers"
Expand All @@ -28,6 +30,7 @@ func testDocker(t *testing.T, context spec.G, it spec.S) {
stage *fakes.DockerStagePhase
start *fakes.DockerStartPhase
teardown *fakes.DockerTeardownPhase
client *fakes.LogsClient
)

it.Before(func() {
Expand All @@ -37,8 +40,9 @@ func testDocker(t *testing.T, context spec.G, it spec.S) {
stage = &fakes.DockerStagePhase{}
start = &fakes.DockerStartPhase{}
teardown = &fakes.DockerTeardownPhase{}
client = &fakes.LogsClient{}

platform = switchblade.NewDocker(initialize, deinitialize, setup, stage, start, teardown)
platform = switchblade.NewDocker(initialize, deinitialize, setup, stage, start, teardown, client)
})

context("Initialize", func() {
Expand Down Expand Up @@ -139,11 +143,9 @@ func testDocker(t *testing.T, context spec.G, it spec.S) {
"Staging...",
"Starting...",
))
Expect(deployment).To(Equal(switchblade.Deployment{
Name: "some-app",
ExternalURL: "some-external-url",
InternalURL: "some-internal-url",
}))
Expect(deployment.Name).To(Equal("some-app"))
Expect(deployment.ExternalURL).To(Equal("some-external-url"))
Expect(deployment.InternalURL).To(Equal("some-internal-url"))

Expect(setup.RunCall.Receives.Ctx).To(Equal(gocontext.Background()))
Expect(setup.RunCall.Receives.Logs).To(Equal(logs))
Expand All @@ -161,6 +163,37 @@ func testDocker(t *testing.T, context spec.G, it spec.S) {
Expect(start.RunCall.Receives.Command).To(Equal("some-command"))
})

it("retrieves runtime logs from deployed application", func() {
client.ContainerLogsCall.Stub = func(ctx gocontext.Context, container string, options container.LogsOptions) (io.ReadCloser, error) {
return io.NopCloser(io.NewSectionReader(
strings.NewReader("Docker runtime log 1\nApplication is running\nDocker runtime log 2\n"),
0,
100,
)), nil
}

deployment, stagingLogs, err := platform.Deploy.Execute("some-app", "/some/path/to/my/app")
Expect(err).NotTo(HaveOccurred())

// Staging logs should contain setup/build output
Expect(stagingLogs.String()).To(ContainSubstring("Setting up..."))
Expect(stagingLogs.String()).To(ContainSubstring("Staging..."))
Expect(stagingLogs.String()).To(ContainSubstring("Starting..."))

// Runtime logs should contain application output
runtimeLogs, err := deployment.RuntimeLogs()
Expect(err).NotTo(HaveOccurred())
Expect(runtimeLogs).To(ContainSubstring("Docker runtime log 1"))
Expect(runtimeLogs).To(ContainSubstring("Application is running"))
Expect(runtimeLogs).To(ContainSubstring("Docker runtime log 2"))

// Verify the client was called with correct parameters
Expect(client.ContainerLogsCall.CallCount).To(Equal(1))
Expect(client.ContainerLogsCall.Receives.Container).To(Equal("some-app"))
Expect(client.ContainerLogsCall.Receives.Options.ShowStdout).To(BeTrue())
Expect(client.ContainerLogsCall.Receives.Options.ShowStderr).To(BeTrue())
})

context("WithBuildpacks", func() {
it("uses those buildpacks", func() {
platform.Deploy.WithBuildpacks("some-buildpack", "other-buildpack")
Expand Down
39 changes: 39 additions & 0 deletions fakes/logs_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package fakes

import (
"context"
"io"
"sync"

"github.com/docker/docker/api/types/container"
)

type LogsClient struct {
ContainerLogsCall struct {
mutex sync.Mutex
CallCount int
Receives struct {
Ctx context.Context
Container string
Options container.LogsOptions
}
Returns struct {
ReadCloser io.ReadCloser
Error error
}
Stub func(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
}
}

func (f *LogsClient) ContainerLogs(ctx context.Context, containerName string, options container.LogsOptions) (io.ReadCloser, error) {
f.ContainerLogsCall.mutex.Lock()
defer f.ContainerLogsCall.mutex.Unlock()
f.ContainerLogsCall.CallCount++
f.ContainerLogsCall.Receives.Ctx = ctx
f.ContainerLogsCall.Receives.Container = containerName
f.ContainerLogsCall.Receives.Options = options
if f.ContainerLogsCall.Stub != nil {
return f.ContainerLogsCall.Stub(ctx, containerName, options)
}
return f.ContainerLogsCall.Returns.ReadCloser, f.ContainerLogsCall.Returns.Error
}
Loading