Skip to content
Open
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
208 changes: 166 additions & 42 deletions integration/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,6 @@ func TestRestoreState(t *testing.T) {
assert.Equal(t, app.Settings.Image, restoredApp.Settings.Image)
}

func TestApplicationVolume(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-volume-label-test")
require.NoError(t, err)

vol1, err := docker.CreateVolume(ctx, ns, "testapp", docker.ApplicationVolumeSettings{SecretKeyBase: "test-secret"})
require.NoError(t, err)
assert.Equal(t, "test-secret", vol1.SecretKeyBase())

vol2, err := docker.FindVolume(ctx, ns, "testapp")
require.NoError(t, err)
assert.Equal(t, vol1.SecretKeyBase(), vol2.SecretKeyBase())

require.NoError(t, vol1.Destroy(ctx))
}

func TestGaplessDeployment(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
Expand All @@ -122,9 +104,12 @@ func TestGaplessDeployment(t *testing.T) {
Host: "gapless.localhost",
})

firstSecretKeyBase := app.Settings.Keys.SecretKeyBase
require.NotEmpty(t, firstSecretKeyBase)

vol, err := app.Volume(ctx)
require.NoError(t, err)
firstSecretKeyBase := vol.SecretKeyBase()
assert.Equal(t, firstSecretKeyBase, vol.Keys.SecretKeyBase, "the keys should be copied onto the label of a new volume")

firstName, err := app.ContainerName(ctx)
require.NoError(t, err)
Expand All @@ -137,16 +122,158 @@ func TestGaplessDeployment(t *testing.T) {
countAfter := countContainers(t, ctx, containerPrefix)
assert.Equal(t, countBefore, countAfter, "container count should not change")

vol2, err := app.Volume(ctx)
require.NoError(t, err)
assert.Equal(t, firstSecretKeyBase, vol2.SecretKeyBase(), "SecretKeyBase should persist across deploys")
assert.Equal(t, firstSecretKeyBase, app.Settings.Keys.SecretKeyBase, "SecretKeyBase should persist across deploys")

secondName, err := app.ContainerName(ctx)
require.NoError(t, err)
assert.NotEqual(t, firstName, secondName, "container name should change between deploys")

require.NoError(t, ns.Refresh(ctx))
assert.Len(t, ns.Applications(), 1, "should have exactly one application after redeploy and refresh")
assert.Equal(t, firstSecretKeyBase, ns.Applications()[0].Settings.Keys.SecretKeyBase, "SecretKeyBase should round-trip through the container label")
}

func TestResetSecrets(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-reset-secrets-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

app := deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "resetapp",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "resetapp.localhost",
})

containerName, err := app.ContainerName(ctx)
require.NoError(t, err)
execInContainer(t, ctx, containerName, []string{
"sh", "-c", "echo 'sentinel' > /rails/storage/reset-test.txt",
})

oldKeys := app.Settings.Keys
require.NotEmpty(t, oldKeys.SecretKeyBase)

containerPrefix := "once-reset-secrets-test-app-resetapp-"
countBefore := countContainers(t, ctx, containerPrefix)

require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{GenerateSecretKeyBase: true}, nil))

assert.Equal(t, countBefore, countContainers(t, ctx, containerPrefix), "container count should not change")

assert.NotEqual(t, oldKeys.SecretKeyBase, app.Settings.Keys.SecretKeyBase, "SecretKeyBase should change")
assert.Equal(t, oldKeys.VAPIDPublicKey, app.Settings.Keys.VAPIDPublicKey, "VAPID keys should be preserved")
assert.Equal(t, oldKeys.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey, "VAPID keys should be preserved")

newContainerName, err := app.ContainerName(ctx)
require.NoError(t, err)
envVars := inspectContainerEnv(t, ctx, newContainerName)
assert.Contains(t, envVars, "SECRET_KEY_BASE="+app.Settings.Keys.SecretKeyBase)

execInContainer(t, ctx, newContainerName, []string{
"test", "-f", "/rails/storage/reset-test.txt",
})

require.NoError(t, ns.Refresh(ctx))
refreshed := ns.ApplicationByHost("resetapp.localhost")
require.NotNil(t, refreshed)
assert.Equal(t, app.Settings.Keys, refreshed.Settings.Keys, "rotated keys should persist in the container label")

currentSecretKeyBase := app.Settings.Keys.SecretKeyBase
require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{GenerateVAPID: true}, nil))

assert.Equal(t, currentSecretKeyBase, app.Settings.Keys.SecretKeyBase, "SecretKeyBase should be preserved")
assert.NotEqual(t, oldKeys.VAPIDPublicKey, app.Settings.Keys.VAPIDPublicKey, "VAPID keys should change")
assert.NotEqual(t, oldKeys.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey, "VAPID keys should change")

vapidContainerName, err := app.ContainerName(ctx)
require.NoError(t, err)
envVars = inspectContainerEnv(t, ctx, vapidContainerName)
assert.Contains(t, envVars, "VAPID_PUBLIC_KEY="+app.Settings.Keys.VAPIDPublicKey)
}

func TestResetSecretsWithProvidedValues(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-provided-secrets-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

app := deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "providedapp",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "providedapp.localhost",
})

provided := docker.GenerateKeys()
require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{
SecretKeyBase: "provided-secret-key-base",
VAPIDPrivateKey: provided.VAPIDPrivateKey,
}, nil))

assert.Equal(t, "provided-secret-key-base", app.Settings.Keys.SecretKeyBase)
assert.Equal(t, provided.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey)
assert.Equal(t, provided.VAPIDPublicKey, app.Settings.Keys.VAPIDPublicKey, "the public key should be derived from the provided private key")

containerName, err := app.ContainerName(ctx)
require.NoError(t, err)
envVars := inspectContainerEnv(t, ctx, containerName)
assert.Contains(t, envVars, "SECRET_KEY_BASE=provided-secret-key-base")
assert.Contains(t, envVars, "VAPID_PUBLIC_KEY="+provided.VAPIDPublicKey)
assert.Contains(t, envVars, "VAPID_PRIVATE_KEY="+provided.VAPIDPrivateKey)

require.NoError(t, ns.Refresh(ctx))
refreshed := ns.ApplicationByHost("providedapp.localhost")
require.NotNil(t, refreshed)
assert.Equal(t, app.Settings.Keys, refreshed.Settings.Keys, "provided keys should persist in the container label")

err = app.ResetSecrets(ctx, docker.SecretsReset{VAPIDPrivateKey: "nonsense"}, nil)
assert.ErrorIs(t, err, docker.ErrInvalidVAPIDPrivateKey)
assert.Equal(t, provided.VAPIDPrivateKey, app.Settings.Keys.VAPIDPrivateKey, "an invalid request should leave the keys alone")
}

func TestDeployKeyMigration(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

ns, err := docker.NewNamespace("once-key-migration-test")
require.NoError(t, err)
defer ns.Teardown(ctx, true)

require.NoError(t, ns.EnsureNetwork(ctx))
require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t)))

// A volume with keys on its label, as older versions created
legacyKeys := docker.GenerateKeys().WithSecretKeyBase("legacy-secret")
_, err = docker.CreateVolume(ctx, ns, "legacyapp", legacyKeys)
require.NoError(t, err)

app := deployApp(t, ctx, ns, docker.ApplicationSettings{
Name: "legacyapp",
Image: "ghcr.io/basecamp/once-campfire:main",
Host: "legacyapp.localhost",
})
assert.Equal(t, legacyKeys, app.Settings.Keys, "keys should be adopted from the volume label")

containerName, err := app.ContainerName(ctx)
require.NoError(t, err)
envVars := inspectContainerEnv(t, ctx, containerName)
assert.Contains(t, envVars, "SECRET_KEY_BASE=legacy-secret")

require.NoError(t, app.ResetSecrets(ctx, docker.SecretsReset{GenerateSecretKeyBase: true}, nil))
require.NoError(t, ns.Refresh(ctx))
refreshed := ns.ApplicationByHost("legacyapp.localhost")
require.NotNil(t, refreshed)
assert.NotEqual(t, "legacy-secret", refreshed.Settings.Keys.SecretKeyBase, "settings keys should take precedence over the volume label")
}

func TestUpdateDetectsLocalImageChange(t *testing.T) {
Expand Down Expand Up @@ -391,9 +518,10 @@ func TestBackup(t *testing.T) {
assert.Equal(t, imageName, appSettings.Image)

assert.Contains(t, entries, "once.volume.json")
var volSettings docker.ApplicationVolumeSettings
require.NoError(t, json.Unmarshal(entries["once.volume.json"], &volSettings))
assert.NotEmpty(t, volSettings.SecretKeyBase)
var volKeys docker.KeysSettings
require.NoError(t, json.Unmarshal(entries["once.volume.json"], &volKeys))
assert.NotEmpty(t, volKeys.SecretKeyBase)
assert.Equal(t, appSettings.Keys.SecretKeyBase, volKeys.SecretKeyBase, "keys should be duplicated into the volume settings entry")

assert.Contains(t, entries, "data/testfile.txt")
assert.Equal(t, "test content\n", string(entries["data/testfile.txt"]))
Expand Down Expand Up @@ -425,9 +553,8 @@ func TestRestore(t *testing.T) {
"sh", "-c", "echo 'restore test data' > /rails/storage/restore-test.txt",
})

vol, err := app.Volume(ctx)
require.NoError(t, err)
originalSecretKeyBase := vol.SecretKeyBase()
originalSecretKeyBase := app.Settings.Keys.SecretKeyBase
require.NotEmpty(t, originalSecretKeyBase)

backupDir := t.TempDir()
require.NoError(t, app.BackupToFile(ctx, backupDir, "backup.tar.gz"))
Expand Down Expand Up @@ -457,10 +584,12 @@ func TestRestore(t *testing.T) {
assert.NotNil(t, ns2.Application(restoredApp.Settings.Name), "app should be in namespace immediately after Restore")
assert.True(t, ns2.HostInUse("restore.localhost"), "hostname should be in use after Restore")

// Verify volume settings (SecretKeyBase) were preserved
// Verify the keys were preserved
assert.Equal(t, originalSecretKeyBase, restoredApp.Settings.Keys.SecretKeyBase)

restoredVol, err := restoredApp.Volume(ctx)
require.NoError(t, err)
assert.Equal(t, originalSecretKeyBase, restoredVol.SecretKeyBase())
assert.Equal(t, originalSecretKeyBase, restoredVol.Keys.SecretKeyBase, "the keys should be copied onto the restored volume label")

// Verify data was restored
restoredContainerName, err := restoredApp.ContainerName(ctx)
Expand All @@ -480,9 +609,7 @@ func TestRestore(t *testing.T) {
assert.Equal(t, imageName, restoredAppFromState.Settings.Image)
assert.Equal(t, "restore.localhost", restoredAppFromState.Settings.Host)

volFromState, err := restoredAppFromState.Volume(ctx)
require.NoError(t, err)
assert.Equal(t, originalSecretKeyBase, volFromState.SecretKeyBase(), "volume SecretKeyBase should be preserved")
assert.Equal(t, originalSecretKeyBase, restoredAppFromState.Settings.Keys.SecretKeyBase, "SecretKeyBase should be preserved")
}

func TestRestoreHostnameConflictFails(t *testing.T) {
Expand Down Expand Up @@ -825,9 +952,8 @@ func TestUpdatePreservesSettings(t *testing.T) {
Resources: docker.ContainerResources{CPUs: 2, MemoryMB: 1024},
})

vol, err := app.Volume(ctx)
require.NoError(t, err)
originalSecretKeyBase := vol.SecretKeyBase()
originalSecretKeyBase := app.Settings.Keys.SecretKeyBase
require.NotEmpty(t, originalSecretKeyBase)

// Update only the env vars, leaving everything else as-is
newSettings := app.Settings
Expand Down Expand Up @@ -855,10 +981,8 @@ func TestUpdatePreservesSettings(t *testing.T) {
assert.Contains(t, envVars, "NEW_VAR=new_value")
assertEnvAbsent(t, envVars, "MY_VAR")

// Volume preserved
vol2, err := updatedApp.Volume(ctx)
require.NoError(t, err)
assert.Equal(t, originalSecretKeyBase, vol2.SecretKeyBase())
// Keys preserved
assert.Equal(t, originalSecretKeyBase, updatedApp.Settings.Keys.SecretKeyBase)
}

func TestUpdateChangeHost(t *testing.T) {
Expand Down Expand Up @@ -1216,7 +1340,7 @@ func buildTestBackup(t *testing.T, imageName string) []byte {
Image: imageName,
Host: "hookapp.localhost",
}
volSettings := docker.ApplicationVolumeSettings{SecretKeyBase: "test-secret-key"}
volKeys := docker.KeysSettings{SecretKeyBase: "test-secret-key"}

var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
Expand All @@ -1230,7 +1354,7 @@ func buildTestBackup(t *testing.T, imageName string) []byte {
}

writeEntry("once.application.json", []byte(appSettings.Marshal()))
writeEntry("once.volume.json", []byte(volSettings.Marshal()))
writeEntry("once.volume.json", []byte(volKeys.Marshal()))

// Add data directory with a marker file for hook testing.
// Use UID/GID 1000 to match realistic backup ownership.
Expand Down
13 changes: 1 addition & 12 deletions internal/command/cli_progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package command

import (
"fmt"
"os"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
Expand Down Expand Up @@ -40,7 +39,7 @@ func newCLIProgress(label string, task func(docker.DeployProgressCallback) error
func runWithProgress(label string, task func(docker.DeployProgressCallback) error) error {
var err error

if isTerminal() {
if stdoutIsTerminal() {
p := newCLIProgress(label, task)
if _, runErr := tea.NewProgram(p).Run(); runErr != nil {
return runErr
Expand Down Expand Up @@ -127,13 +126,3 @@ func (m *cliProgress) waitForProgress() tea.Cmd {
return cliProgressUpdateMsg{p: p}
}
}

// Helpers

func isTerminal() bool {
fi, err := os.Stdout.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
Loading
Loading