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
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Sith

**Status: Slice 3 local fleet client.** The CLI discovers every context resolved by client-go,
hydrates a local in-memory fleet cache through per-context watches, serves coverage-honest fleet
search, and provides explicit-context logs, exec, port-forward, describe, and YAML view/edit.
**Status: Slice 4 local fleet client.** The CLI and embedded browser IDE discover every context
resolved by client-go, hydrate one local in-memory fleet cache through per-context watches, serve
coverage-honest fleet search/correlation, and provide explicit-context logs, exec, port-forward,
describe, and YAML view/edit.

Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**.
It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster
Expand All @@ -29,6 +30,7 @@ make build
./bin/sith exec api --context kind-dev -n apps -it -- /bin/sh
./bin/sith port-forward service/api --context kind-dev -n apps :http
./bin/sith edit configmap/api-settings --context kind-dev -n apps
./bin/sith ui # loopback-only embedded fleet IDE
```

`sith clusters` follows standard client-go loading rules: set `KUBECONFIG` to an OS path-list or
Expand All @@ -54,6 +56,15 @@ active one.
The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or
component dependency enters the binary.

`sith ui` serves a build-free frontend embedded in the same Go binary. It binds to
`127.0.0.1` on an available port by default; `--address` accepts loopback addresses only and
`--no-open` suppresses browser launch. The browser renders the same cache, lenses, ordering,
coverage, search/correlation grammar, and per-resource operations as the CLI/TUI. Its local HTTP
boundary requires an exact Host/Origin and a per-process capability header, uses a restrictive
Content Security Policy, and loads no remote assets. YAML apply additionally requires a short-lived,
single-use server preview token bound to the exact target and manifest; Secret edit requires an
explicit reveal-and-edit confirmation before unredacted data enters the browser.

Local resource operations always require or derive one explicit cached context and use that
context's existing kubeconfig identity directly. They are deliberately separate from Sith's
governed Intent/PEP action model. Secret YAML is redacted unless `--show-secrets` is explicit;
Expand All @@ -74,10 +85,11 @@ make ci
```

The real multi-cluster gate creates two temporary kind clusters with a digest-pinned node image,
checks one additional unreachable context, and proves context-isolated logs, exec, YAML/Secret
handling, describe/events, dry-run edit, and loopback TCP forwarding against a scratch fixture
image. It removes both clusters afterward. The gate requires a running Docker engine and kind
v0.32.0, and consumes additional CI time, disk, and memory:
checks one additional unreachable context, and proves CLI plus web-IDE context isolation for
search/correlation, logs, exec, YAML/Secret handling, describe/events, preview-gated edit, and
loopback TCP forwarding against a scratch fixture image. It removes both clusters afterward. The
gate requires a running Docker engine and kind v0.32.0, and consumes additional CI time, disk,
and memory:

```bash
make e2e-kind
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
k8s.io/api v0.36.2
k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2
k8s.io/streaming v0.36.2
sigs.k8s.io/yaml v1.6.0
)

Expand Down Expand Up @@ -61,7 +62,6 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/streaming v0.36.2 // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ func TestClustersUsesInjectedSource(t *testing.T) {
}
}

func TestUIStub(t *testing.T) {
stdout, _, exitCode := runCLI(t, []string{"ui"}, fleet.StubSource{})
if exitCode != 0 || stdout != "sith ui: not yet implemented — see F11.3 (#34).\n" {
t.Fatalf("exit/stdout = %d/%q", exitCode, stdout)
func TestUIRequiresLocalBackend(t *testing.T) {
stdout, stderr, exitCode := runCLI(t, []string{"ui", "--no-open"}, fleet.StubSource{})
if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "requires a Kubernetes reader") {
t.Fatalf("exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr)
}
}

Expand Down
35 changes: 35 additions & 0 deletions internal/cli/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"sync"
"testing"

"github.com/ArdurAI/sith/internal/connector"
"github.com/ArdurAI/sith/internal/fleet"
"github.com/ArdurAI/sith/internal/localops"
)
Expand Down Expand Up @@ -129,6 +130,24 @@ func TestEditSurfacesServerDryRunRejectionWithoutApply(t *testing.T) {
}
}

func TestUIRefusesExternalBindAndStartsOnLoopback(t *testing.T) {
reader := &cacheReader{}
client := &fakeLocalClient{}
_, stderr, exitCode := runUICLI(context.Background(), t, []string{
"ui", "--address", "0.0.0.0", "--no-open",
}, reader, client)
if exitCode == 0 || !strings.Contains(stderr, "not loopback") {
t.Fatalf("external bind exit/stderr = %d/%q", exitCode, stderr)
}

ctx, cancel := context.WithCancel(context.Background())
cancel()
stdout, stderr, exitCode := runUICLI(ctx, t, []string{"ui", "--port", "0", "--no-open"}, reader, client)
if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "sith ui listening on http://127.0.0.1:") {
t.Fatalf("loopback UI exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr)
}
}

func runLocalCLI(
t *testing.T,
args []string,
Expand All @@ -146,6 +165,22 @@ func runLocalCLI(
return stdoutBuffer.String(), stderrBuffer.String(), exitCode
}

func runUICLI(
ctx context.Context,
t *testing.T,
args []string,
reader connector.Reader,
client localops.Client,
) (stdout, stderr string, exitCode int) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
var stdoutBuffer, stderrBuffer bytes.Buffer
exitCode = executeBackendContext(ctx, args, backend{
source: connector.AsSource(reader), reader: reader, local: client, tuiInput: strings.NewReader(""),
}, &stdoutBuffer, &stderrBuffer)
return stdoutBuffer.String(), stderrBuffer.String(), exitCode
}

type fakeLocalClient struct {
mu sync.Mutex
callCount int
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command {
commands := []*cobra.Command{
newVersionCommand(options),
newClustersCommand(options, runtime.source),
newUICommand(),
newUICommand(runtime.reader, runtime.local),
newHubCommand(),
}
if runtime.reader != nil {
Expand Down
123 changes: 117 additions & 6 deletions internal/cli/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,132 @@
package cli

import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os/exec"
"runtime"
"strconv"
"time"

"github.com/spf13/cobra"

"github.com/ArdurAI/sith/internal/connector"
"github.com/ArdurAI/sith/internal/fleetcache"
"github.com/ArdurAI/sith/internal/hydrate"
"github.com/ArdurAI/sith/internal/localops"
"github.com/ArdurAI/sith/internal/webui"
)

func newUICommand() *cobra.Command {
return &cobra.Command{
type uiOptions struct {
address string
port int
noOpen bool
}

func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command {
options := &uiOptions{address: "127.0.0.1"}
command := &cobra.Command{
Use: "ui",
Short: "Start the local fleet IDE",
Short: "Start the loopback-only local fleet IDE",
Args: cobra.NoArgs,
RunE: func(command *cobra.Command, _ []string) error {
if _, err := fmt.Fprintln(command.OutOrStdout(), "sith ui: not yet implemented — see F11.3 (#34)."); err != nil {
return fmt.Errorf("write ui status: %w", err)
if reader == nil || local == nil {
return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client")
}
return nil
return runWebUI(command.Context(), command, reader, local, options)
},
}
command.Flags().StringVar(&options.address, "address", options.address, "loopback listen address")
command.Flags().IntVar(&options.port, "port", 0, "loopback listen port; 0 selects an available port")
command.Flags().BoolVar(&options.noOpen, "no-open", false, "do not open the system browser")
return command
}

func runWebUI(
ctx context.Context,
command *cobra.Command,
reader connector.Reader,
local localops.Client,
options *uiOptions,
) error {
if err := webui.ValidateLoopbackAddress(options.address); err != nil {
return err
}
if options.port < 0 || options.port > 65535 {
return fmt.Errorf("local web UI port must be between 0 and 65535")
}
listener, err := net.Listen("tcp", net.JoinHostPort(options.address, strconv.Itoa(options.port)))
if err != nil {
return fmt.Errorf("listen for local fleet UI: %w", err)
}
defer func() { _ = listener.Close() }()
tcpAddress, ok := listener.Addr().(*net.TCPAddr)
if !ok {
return fmt.Errorf("local fleet UI listener returned an unexpected address type")
}
origin := "http://" + net.JoinHostPort(options.address, strconv.Itoa(tcpAddress.Port))
store := fleetcache.New()
hydrator, err := hydrate.New(reader, store)
if err != nil {
return err
}
application, err := webui.New(ctx, store, hydrator, local)
if err != nil {
return err
}
defer func() { _ = application.Close() }()
handler, err := application.Handler(origin)
if err != nil {
return err
}
server := &http.Server{
Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 0,
IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 32 << 10,
}
serverErrors := make(chan error, 1)
go func() { serverErrors <- server.Serve(listener) }()
go func() { _ = hydrator.Run(ctx) }()
if _, err := fmt.Fprintf(command.OutOrStdout(), "sith ui listening on %s\n", origin); err != nil {
return fmt.Errorf("write local fleet UI address: %w", err)
}
if !options.noOpen {
go func() {
if err := openBrowser(origin); err != nil {
_, _ = fmt.Fprintf(command.ErrOrStderr(), "warning: open browser: %v\n", err)
}
}()
}
var serveErr error
select {
case <-ctx.Done():
case serveErr = <-serverErrors:
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := server.Shutdown(shutdownCtx)
if errors.Is(serveErr, http.ErrServerClosed) {
serveErr = nil
}
return errors.Join(serveErr, shutdownErr)
}

func openBrowser(url string) error {
var name string
var arguments []string
switch runtime.GOOS {
case "darwin":
name, arguments = "open", []string{url}
case "windows":
name, arguments = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
name, arguments = "xdg-open", []string{url}
}
// #nosec G204 -- executable names are fixed above and the URL is a generated loopback origin.
if err := exec.Command(name, arguments...).Start(); err != nil {
return err
}
return nil
}
Loading