package e2e import ( "bytes" "context" "fmt" "strings" "testing" "time" "github.com/psviderski/uncloud/internal/ucind" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // deployTestService is a helper function to deploy a simple alpine service for testing exec func deployTestService(t *testing.T, ctx context.Context, cli *client.Client, name string, replicas uint) { t.Helper() spec := api.ServiceSpec{ Name: name, Replicas: replicas, Container: api.ContainerSpec{ Image: "alpine:3.20", Command: []string{"sleep", "3600"}, }, } deployment := cli.NewDeployment(spec, nil) err := deployment.Validate(ctx) require.NoError(t, err) _, err = deployment.Run(ctx) require.NoError(t, err) // Wait for all replicas to be running require.Eventually(t, func() bool { service, err := cli.InspectService(ctx, name) if err != nil { return false } if uint(len(service.Containers)) != replicas { return false } for _, ctr := range service.Containers { if ctr.Container.State.Status != "running" { return false } } return true }, 30*time.Second, 1*time.Second, fmt.Sprintf("service %s should have %d running replicas", name, replicas)) } // TestExecBasicCommands tests basic command execution, errors, and stderr handling func TestExecBasicCommands(t *testing.T) { t.Parallel() clusterName := "ucind-test.exec-basic" ctx := context.Background() c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true) cli, err := c.Machines[0].Connect(ctx) require.NoError(t, err) // Test: Non-existent service should fail t.Run("non-existent service", func(t *testing.T) { execOptions := api.ExecOptions{ Command: []string{"echo", "test"}, AttachStdout: true, AttachStderr: true, } _, err := cli.ExecContainer(ctx, "non-existent-service", "", execOptions) assert.Error(t, err, "should fail for non-existent service") assert.Contains(t, strings.ToLower(err.Error()), "inspect service: not found") }) // Deploy a simple service for all remaining tests serviceName := "test-exec-service" deployTestService(t, ctx, cli, serviceName, 1) // Test: Execute a simple echo command t.Run("echo command", func(t *testing.T) { var stdout, stderr bytes.Buffer execOptions := api.ExecOptions{ Command: []string{"echo", "hello world"}, AttachStdout: true, AttachStderr: true, Stdout: &stdout, Stderr: &stderr, } exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) require.NoError(t, err) assert.Equal(t, 0, exitCode, "command should exit with code 0") assert.Equal(t, "hello world\n", stdout.String(), "should capture stdout") assert.Empty(t, stderr.String(), "stderr should be empty") }) // Test: Command with non-zero exit code t.Run("non-zero exit code", func(t *testing.T) { execOptions := api.ExecOptions{ Command: []string{"sh", "-c", "exit 42"}, AttachStdout: true, AttachStderr: true, } exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) require.NoError(t, err) assert.Equal(t, 42, exitCode, "should return actual exit code") }) // Test: Invalid command should fail t.Run("invalid command", func(t *testing.T) { var stdout, stderr bytes.Buffer execOptions := api.ExecOptions{ Command: []string{"nonexistent-command"}, AttachStdout: true, AttachStderr: true, Stdout: &stdout, Stderr: &stderr, } exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) require.NoError(t, err, "exec should not fail, but command should return non-zero exit") assert.Equal(t, 126, exitCode, "invalid command should return non-zero exit code") assert.Contains(t, stdout.String(), "executable file not found") assert.Equal(t, "", stderr.String(), "stderr should be empty") }) // Test: Non-existent container ID t.Run("non-existent container", func(t *testing.T) { execOptions := api.ExecOptions{ Command: []string{"echo", "test"}, AttachStdout: true, AttachStderr: true, } _, err := cli.ExecContainer(ctx, serviceName, "non-existent-container-id", execOptions) assert.Error(t, err, "should fail for non-existent container") }) // Test: Empty command t.Run("empty command", func(t *testing.T) { execOptions := api.ExecOptions{ Command: []string{}, AttachStdout: true, AttachStderr: true, } _, err := cli.ExecContainer(ctx, serviceName, "", execOptions) assert.Error(t, err, "should fail for empty command") }) // Test: Command with both stdout and stderr t.Run("mixed stdout/stderr", func(t *testing.T) { var stdout, stderr bytes.Buffer execOptions := api.ExecOptions{ Command: []string{"sh", "-c", "echo 'stdout'; echo 'stderr' >&2"}, AttachStdout: true, AttachStderr: true, Stdout: &stdout, Stderr: &stderr, } exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) require.NoError(t, err) assert.Equal(t, 0, exitCode) assert.Equal(t, "stdout\n", stdout.String(), "should capture stdout") assert.Equal(t, "stderr\n", stderr.String(), "should capture stderr") }) // Test: Detached command should return immediately t.Run("detached command", func(t *testing.T) { var stdout, stderr bytes.Buffer start := time.Now() execOptions := api.ExecOptions{ Command: []string{"sh", "-c", "sleep 10; echo hello"}, // Long-running command Stdout: &stdout, Stderr: &stderr, Detach: true, } exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) elapsed := time.Since(start) require.NoError(t, err) assert.Equal(t, 0, exitCode) // Should return immediately, not wait for command to complete assert.Less(t, elapsed, 5*time.Second, "detached command should return quickly") assert.Empty(t, stdout.String(), "stdout should be empty for detached command") assert.Empty(t, stderr.String(), "stderr should be empty for detached command") }) // Test: Detached mode with invalid command t.Run("detached invalid command", func(t *testing.T) { var stdout, stderr bytes.Buffer execOptions := api.ExecOptions{ Command: []string{"nonexistent-detached-command"}, Stdout: &stdout, Stderr: &stderr, Detach: true, } exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions) require.ErrorContains(t, err, "executable file not found") assert.Equal(t, 1, exitCode) assert.Empty(t, stdout.String(), "stdout should be empty for detached command") assert.Empty(t, stderr.String(), "stderr should be empty for detached command") }) // Deploy a service with multiple replicas multiServiceName := "multi-replica-service" deployTestService(t, ctx, cli, multiServiceName, 2) // Test: Execute command on specific container t.Run("exec on specific container", func(t *testing.T) { service, err := cli.InspectService(ctx, multiServiceName) require.NoError(t, err) require.GreaterOrEqual(t, len(service.Containers), 2, "should have at least 2 containers") var stdout, stderr bytes.Buffer // Use container prefix containerIDPrefix := service.Containers[1].Container.ID[:3] execOptions := api.ExecOptions{ Command: []string{"hostname"}, AttachStdout: true, AttachStderr: true, Stdout: &stdout, Stderr: &stderr, } exitCode, err := cli.ExecContainer(ctx, multiServiceName, containerIDPrefix, execOptions) require.NoError(t, err) assert.Equal(t, 0, exitCode) assert.Greater(t, len(stdout.String()), 4) assert.Equal(t, service.Containers[1].Container.Name+"\n", stdout.String()) }) }