Skip to content
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,21 @@ All containers are prefixed `px-` internally. Commands accept bare names (e.g.,

## SSH Access

**Console** opens an interactive SSH session. If the container is stopped, it starts it automatically:
**Console** opens an interactive SSH session with zmx session persistence.
Disconnecting and reconnecting re-attaches to the same session:

```bash
pixels console mybox
pixels console mybox # default "console" session
pixels console mybox -s build # named session
pixels console mybox --no-persist # plain SSH, no zmx
```

Inside a session, press `Ctrl+\` to detach (works in TUIs too), or type `detach`.

**Sessions** lists zmx sessions in a container:

```bash
pixels sessions mybox
```

**Exec** runs a command and returns its exit code:
Expand Down
44 changes: 39 additions & 5 deletions cmd/console.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cmd

import (
"context"
"fmt"
"regexp"
"time"

"github.com/briandowns/spinner"
Expand All @@ -12,19 +14,31 @@ import (
"github.com/deevus/pixels/internal/ssh"
)

var validSessionName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)

func init() {
rootCmd.AddCommand(&cobra.Command{
cmd := &cobra.Command{
Use: "console <name>",
Short: "Open an interactive SSH session",
Short: "Open a persistent SSH session (zmx)",
Args: cobra.ExactArgs(1),
RunE: runConsole,
})
}
cmd.Flags().StringP("session", "s", "console", "zmx session name")
cmd.Flags().Bool("no-persist", false, "skip zmx, use plain SSH")
rootCmd.AddCommand(cmd)
}

func runConsole(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
name := args[0]

session, _ := cmd.Flags().GetString("session")
noPersist, _ := cmd.Flags().GetBool("no-persist")

if !noPersist && !validSessionName.MatchString(session) {
return fmt.Errorf("invalid session name %q: must match [a-zA-Z0-9._-]", session)
}

// Try local cache first for fast path (already running).
var ip string
if cached := cache.Get(name); cached != nil && cached.IP != "" && cached.Status == "RUNNING" {
Expand Down Expand Up @@ -74,7 +88,7 @@ func runConsole(cmd *cobra.Command, args []string) error {
}

// Wait for provisioning to finish before opening the console.
runner := &provision.Runner{Host: ip, User: "root", KeyPath: cfg.SSH.Key}
runner := provision.NewRunner(ip, "root", cfg.SSH.Key)
var spin *spinner.Spinner
if !verbose {
spin = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(cmd.ErrOrStderr()))
Expand All @@ -93,6 +107,26 @@ func runConsole(cmd *cobra.Command, args []string) error {
spin.Stop()
}

cc := ssh.ConnConfig{Host: ip, User: cfg.SSH.User, KeyPath: cfg.SSH.Key, Env: cfg.EnvForward}

// Determine remote command for zmx session persistence.
var remoteCmd string
if !noPersist {
remoteCmd = zmxRemoteCmd(ctx, cc, session)
}

// Console replaces the process — does not return on success.
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key, cfg.EnvForward)
return ssh.Console(cc, remoteCmd)
}

// zmxRemoteCmd checks if zmx is available in the container and returns the
// attach command string. Returns empty string if zmx is not installed.
func zmxRemoteCmd(ctx context.Context, cc ssh.ConnConfig, session string) string {
// Check without env forwarding to avoid polluting the zmx check.
checkCC := ssh.ConnConfig{Host: cc.Host, User: cc.User, KeyPath: cc.KeyPath}
code, err := ssh.ExecQuiet(ctx, checkCC, []string{"command -v zmx >/dev/null 2>&1"})
if err == nil && code == 0 {
return "unset XDG_RUNTIME_DIR && zmx attach " + session + " bash -l"
}
return ""
}
5 changes: 3 additions & 2 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,14 @@ func runCreate(cmd *cobra.Command, args []string) error {
}

if openConsole && ip != "" {
runner := &provision.Runner{Host: ip, User: "root", KeyPath: cfg.SSH.Key}
runner := provision.NewRunner(ip, "root", cfg.SSH.Key)
runner.WaitProvisioned(ctx, func(status string) {
setStatus(status)
logv(cmd, "Provision: %s", status)
})
stopSpinner()
return ssh.Console(ip, cfg.SSH.User, cfg.SSH.Key, cfg.EnvForward)
cc := ssh.ConnConfig{Host: ip, User: cfg.SSH.User, KeyPath: cfg.SSH.Key, Env: cfg.EnvForward}
return ssh.Console(cc, zmxRemoteCmd(ctx, cc, "console"))
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func runExec(cmd *cobra.Command, args []string) error {
return err
}

exitCode, err := ssh.Exec(ctx, ip, cfg.SSH.User, cfg.SSH.Key, command, cfg.EnvForward)
exitCode, err := ssh.Exec(ctx, ssh.ConnConfig{Host: ip, User: cfg.SSH.User, KeyPath: cfg.SSH.Key, Env: cfg.EnvForward}, command)
if err != nil {
return err
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func resolveNetworkContext(cmd *cobra.Command, name string) (*networkContext, er

// sshAsRoot runs a command on the container as root via SSH.
func sshAsRoot(cmd *cobra.Command, ip string, command []string) (int, error) {
return ssh.Exec(cmd.Context(), ip, "root", cfg.SSH.Key, command, nil)
return ssh.Exec(cmd.Context(), ssh.ConnConfig{Host: ip, User: "root", KeyPath: cfg.SSH.Key}, command)
}

func runNetworkShow(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -199,7 +199,7 @@ func runNetworkAllow(cmd *cobra.Command, args []string) error {
}

// Read current domains via SSH.
out, err := ssh.Output(ctx, nc.ip, "root", cfg.SSH.Key, []string{"cat", "/etc/pixels-egress-domains"})
out, err := ssh.Output(ctx, ssh.ConnConfig{Host: nc.ip, User: "root", KeyPath: cfg.SSH.Key}, []string{"cat", "/etc/pixels-egress-domains"})
if err != nil {
return fmt.Errorf("reading domains file: %w", err)
}
Expand Down Expand Up @@ -244,7 +244,7 @@ func runNetworkDeny(cmd *cobra.Command, args []string) error {
cname := containerName(name)

// Read current domains via SSH.
out, err := ssh.Output(ctx, nc.ip, "root", cfg.SSH.Key, []string{"cat", "/etc/pixels-egress-domains"})
out, err := ssh.Output(ctx, ssh.ConnConfig{Host: nc.ip, User: "root", KeyPath: cfg.SSH.Key}, []string{"cat", "/etc/pixels-egress-domains"})
if err != nil {
return fmt.Errorf("no egress policy configured on %s", name)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func readSSHPubKey() (string, error) {
// ensureSSHAuth tests key auth and, if it fails, writes the current machine's
// SSH public key to the container's authorized_keys via TrueNAS.
func ensureSSHAuth(cmd *cobra.Command, ctx context.Context, ip, name string) error {
if err := ssh.TestAuth(ctx, ip, cfg.SSH.User, cfg.SSH.Key); err == nil {
if err := ssh.TestAuth(ctx, ssh.ConnConfig{Host: ip, User: cfg.SSH.User, KeyPath: cfg.SSH.Key}); err == nil {
return nil
}

Expand Down
26 changes: 26 additions & 0 deletions cmd/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ import (
truenas "github.com/deevus/truenas-go"
)

func TestValidSessionName(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"console", "console", true},
{"build", "build", true},
{"my-session", "my-session", true},
{"test.1", "test.1", true},
{"a_b", "a_b", true},
{"empty", "", false},
{"has space", "has space", false},
{"semicolon", "semi;colon", false},
{"backtick", "back`tick", false},
{"newline", "new\nline", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validSessionName.MatchString(tt.input); got != tt.want {
t.Errorf("validSessionName.MatchString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}

func TestContainerName(t *testing.T) {
if got := containerName("my-project"); got != "px-my-project" {
t.Errorf("containerName(my-project) = %q, want %q", got, "px-my-project")
Expand Down
64 changes: 64 additions & 0 deletions cmd/sessions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package cmd

import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/deevus/pixels/internal/provision"
"github.com/deevus/pixels/internal/ssh"
)

func init() {
rootCmd.AddCommand(&cobra.Command{
Use: "sessions <name>",
Short: "List zmx sessions in a container",
Args: cobra.ExactArgs(1),
RunE: runSessions,
})
}

func runSessions(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
name := args[0]

ip, err := resolveRunningIP(ctx, name)
if err != nil {
return err
}

if err := ssh.WaitReady(ctx, ip, 30*time.Second, nil); err != nil {
return fmt.Errorf("waiting for SSH: %w", err)
}

cc := ssh.ConnConfig{Host: ip, User: cfg.SSH.User, KeyPath: cfg.SSH.Key}
out, err := ssh.OutputQuiet(ctx, cc, []string{"unset XDG_RUNTIME_DIR && zmx list"})
if err != nil {
return fmt.Errorf("zmx not available on %s", name)
}

raw := strings.TrimSpace(string(out))
if raw == "" {
fmt.Fprintln(cmd.OutOrStdout(), "No sessions")
return nil
}

sessions := provision.ParseSessions(raw)
if len(sessions) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No sessions")
return nil
}

tw := newTabWriter(cmd)
fmt.Fprintln(tw, "SESSION\tSTATUS")
for _, s := range sessions {
status := "running"
if s.EndedAt != "" {
status = "exited"
}
fmt.Fprintf(tw, "%s\t%s\n", s.Name, status)
}
return tw.Flush()
}
2 changes: 1 addition & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
return fmt.Errorf("waiting for SSH: %w", err)
}

runner := &provision.Runner{Host: ip, User: "root", KeyPath: cfg.SSH.Key}
runner := provision.NewRunner(ip, "root", cfg.SSH.Key)
raw, err := runner.List(ctx)
if err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "No such file") {
Expand Down
Loading