Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5fd8a8e
SHARD-657: save a connection under XDG_CONFIG_HOME, and use it after …
presmihaylov Oct 5, 2026
80de9e5
SHARD-657: the setup skeleton: the verb and its help, the terminal pr…
presmihaylov Oct 5, 2026
b395b46
Merge branch 'shard-657-setup-wizard' of github.com:presmihaylov/shar…
presmihaylov Oct 5, 2026
91b64e1
SHARD-657: reach a server without a request, and read SHARD_CA_FILE f…
presmihaylov Oct 5, 2026
85587a8
Merge branch 'shard-657-setup-wizard' of github.com:presmihaylov/shar…
presmihaylov Oct 5, 2026
26580ea
SHARD-657: setup exits 1 after a stopped step and 130 on Ctrl+C, take…
presmihaylov Oct 5, 2026
fe8accb
Merge branch 'shard-657-setup-wizard' of github.com:presmihaylov/shar…
presmihaylov Oct 5, 2026
0d287c8
SHARD-657: the local setup steps: the provider tools, shard and shard…
presmihaylov Oct 5, 2026
692d0e8
Merge remote-tracking branch 'origin/shard-657-setup-wizard' into sha…
presmihaylov Oct 5, 2026
f384abe
SHARD-657: the install manifest and the release fetch: the pick of th…
presmihaylov Oct 5, 2026
b09898c
SHARD-657: shard setup connects to a remote: verify in three steps, s…
presmihaylov Oct 5, 2026
943580e
Merge remote-tracking branch 'origin/shard-657-setup-wizard' into sha…
presmihaylov Oct 5, 2026
1821205
SHARD-657: an existing installation: check or repair, upgrade to the …
presmihaylov Oct 5, 2026
36e5cea
Merge branch 'shard-657-setup-wizard' of github.com:presmihaylov/shar…
presmihaylov Oct 5, 2026
779b426
SHARD-657: the user's shard stays executable after an upgrade, and go…
presmihaylov Oct 5, 2026
a12ee6a
SHARD-657: an edit after a failed check asks for the key, even with S…
presmihaylov Oct 5, 2026
9efdb6b
Merge remote-tracking branch 'origin/shard-657-setup-wizard' into sha…
presmihaylov Oct 5, 2026
6e02200
SHARD-657: a repair or an upgrade offers to drop a saved remote, as a…
presmihaylov Oct 5, 2026
beea7a8
SHARD-657: stage the service files at 0600, since install sets the mo…
presmihaylov Oct 5, 2026
e8d3ced
SHARD-657: an unreachable server says why and what to check, under th…
presmihaylov Oct 5, 2026
ff99236
Merge remote-tracking branch 'origin/shard-657-setup-wizard' into sha…
presmihaylov Oct 5, 2026
d1adf5d
SHARD-657: preflight, review and the local flow, with tests
presmihaylov Oct 5, 2026
19e1bb4
Merge remote-tracking branch 'origin/shard-657-setup-wizard' into sha…
presmihaylov Oct 5, 2026
dfd3db6
SHARD-657: setup checks shard-init against SHA256SUMS; shard is the r…
presmihaylov Oct 5, 2026
04e5f12
Merge remote-tracking branch 'origin/main' into shard-657-setup-wizard
presmihaylov Oct 5, 2026
65da6fb
SHARD-657: preflight marks the block-size conversion gosec flags on L…
presmihaylov Oct 5, 2026
08ab3f7
SHARD-657: a failed verify says what the daemon reported, not the sud…
presmihaylov Oct 5, 2026
2c2eb66
SHARD-657: a table test over every row of the remote order: --remote,…
presmihaylov Oct 5, 2026
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
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ linters:
- $gostd
- github.com/presmihaylov/shard/models
- github.com/presmihaylov/shard/pkg/pty
- github.com/presmihaylov/shard/pkg/term
- github.com/presmihaylov/shard/pkg/vzshim
- github.com/presmihaylov/shard/services/client
- github.com/presmihaylov/shard/services/sandbox
- github.com/presmihaylov/shard/services/daemon
- github.com/presmihaylov/shard/services/serve
- github.com/presmihaylov/shard/services/setup
# models/ is a leaf so that it never participates in an import cycle.
models-is-a-leaf:
files:
Expand Down
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pkg/reflink/ one clone by reference, and whether a directory's fil
pkg/xfs/ mkfs.xfs, the loop mount and the fstab line of one image
pkg/vz/ the Virtualization.framework driver: the shim protocol, its client and its server
pkg/vzshim/ the shim binary embedded in the daemon, installed and ad-hoc signed on first use
pkg/term/ the terminal prompts and the live checklist shard setup draws

services/sandbox/ the orchestrator: the lifecycle verbs the daemon serves
services/image/ pull, unpack, cache policy
Expand All @@ -87,6 +88,7 @@ services/daemon/ shard daemon: the wiring of every layer, and the back
services/api/ the REST handlers the daemon serves over its unix socket
services/client/ the typed client of that API, which the thin CLI verbs call
services/serve/ the TCP front: a bearer token, and the bytes onto that socket
services/setup/ shard setup: inspect this host, install a provider and the daemon, or save a remote
services/provider/gvisor/ implements models.Provider on gVisor
services/provider/sysbox/ implements models.Provider on Sysbox
services/provider/runc/ implements models.Provider on bare runc
Expand All @@ -111,9 +113,10 @@ website/ useshards.com: Astro + Starlight, landing at /, docs
a driver and it belongs in `services/`. `depguard` enforces this in CI.
- **Dependencies point one way: `cli` to `services` to `pkg`.** `models` sits
under all of them.
- **`cli/` imports `services/client`, `pkg/pty`, `pkg/vzshim`, `models`, the request
types in `services/sandbox`, and `services/daemon` and `services/serve` for the
two verbs that are a process rather than a client. Nothing else.** A verb holds no
- **`cli/` imports `services/client`, `pkg/pty`, `pkg/term`, `pkg/vzshim`, `models`, the
request types in `services/sandbox`, and `services/daemon`, `services/serve` and
`services/setup` for the three verbs that are a process rather than a client.
Nothing else.** A verb holds no
store and no provider: it asks the socket.
`depguard` enforces the allow list in CI.
- **`models/` is one package with several files, and it is a leaf.** It imports
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ are in those directories, and [the release guide](docs/release.md) says how an S

## Documentation

- [Set up a host or a remote connection](docs/setup.md)
- [CLI commands and options](docs/cli.md)
- [Daemon, REST API, and remote access](docs/daemon.md)
- [Provider capabilities and limits](docs/provider.md)
Expand Down
45 changes: 42 additions & 3 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package cli

import (
"cmp"
"context"
"errors"
"flag"
Expand Down Expand Up @@ -54,6 +55,9 @@ type App struct {

// plainWarned is shared by every copy of the App one run makes, so a verb that builds two clients warns once.
plainWarned *sync.Once

// remoteFlag says --remote was passed, so even an empty one names the target and the saved connection names none.
remoteFlag bool
}

// stdin is what exec hands the guest and what secret set reads the value from.
Expand Down Expand Up @@ -254,6 +258,7 @@ func commands() []command {
{name: "daemon", run: App.daemon, subs: []command{{name: "status", run: App.daemonStatus}}},
{name: "info", run: App.info},
{name: "serve", run: App.serve},
{name: "setup", run: App.setup},
{name: "tokens", subs: []command{
{name: "mint", run: App.tokensMint},
{name: "list", aliases: []string{"ls"}, run: App.tokensList},
Expand Down Expand Up @@ -388,6 +393,7 @@ func (a *App) parseGlobals(args []string) ([]string, error) {
if err := parseVerb(flags, args); err != nil {
return nil, err
}
flags.Visit(func(f *flag.Flag) { a.remoteFlag = a.remoteFlag || f.Name == "remote" })

// --version answers before the root is checked, so it never fails.
if showVersion {
Expand Down Expand Up @@ -435,11 +441,15 @@ func (h *hostList) Set(value string) error {
return nil
}

// client speaks to the daemon on the socket, or through --remote; a verb asks only after its flags parsed, so --help reads no token.
// client speaks to the daemon on the socket, or through --remote, SHARD_REMOTE or the saved connection; a verb asks only after its flags parsed, so --help reads no token.
func (a App) client() (*client.Client, error) {
saved, err := a.saved()
if err != nil {
return nil, err
}
// The key and the certificate are read here, so a bad one fails before the verb dials.
if a.Remote != "" {
c, err := client.NewRemoteFromEnv(a.Remote)
if remote := cmp.Or(a.Remote, saved.Remote); remote != "" {
Comment thread
presmihaylov marked this conversation as resolved.
c, err := client.NewRemoteFromEnv(remote, saved)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -469,13 +479,42 @@ func (a App) localClient(verb string) (*client.Client, error) {

// hostOnly refuses a remote for a verb that acts on this host, so it never reports the local result as the server's.
func (a App) hostOnly(verb string) error {
if err := a.noRemote(verb); err != nil {
return err
}
saved, err := a.saved()
if err != nil {
return err
}
if saved.Remote == "" {
return nil
}

return fmt.Errorf("shard %s runs on the daemon host only and cannot reach the %v; remove it with shard setup to run it here", verb, saved)
}

// noRemote is hostOnly for daemon and serve: the saved connection names where commands go, never where a daemon runs.
func (a App) noRemote(verb string) error {
if a.Remote == "" {
return nil
}

return fmt.Errorf("shard %s runs on the daemon host only and cannot reach %s; unset --remote and %s to run it there", verb, a.Remote, client.RemoteEnv)
}

// saved is the connection shard setup saved; an explicit --remote "" asks for the socket, so it reads none.
func (a App) saved() (client.Config, error) {
if a.remoteFlag && a.Remote == "" {
return client.Config{}, nil
}
path, err := client.ConfigPath(os.Getenv)
if err != nil {
return client.Config{}, err
}

return client.LoadConfig(path)
}

// gotArgs echoes what a verb refused, quoted, so the error shows what was typed rather than a count.
func gotArgs(args []string) string {
if len(args) == 0 {
Expand Down
162 changes: 162 additions & 0 deletions cli/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package cli

import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/presmihaylov/shard/services/client"
)

// leakKey is a synthetic key no real credential holds, so any text that carries it is a leak.
const leakKey = "shard657-synthetic-key-5a4b3c2d"

// isolateConfig points the saved connection at an empty directory of its own, so no test reads or writes the user's.
func isolateConfig() (func() error, error) {
dir, err := os.MkdirTemp("", "shard-config")
if err != nil {
return nil, fmt.Errorf("make a configuration directory: %w", err)
}
if err := os.Setenv(client.ConfigHomeEnv, dir); err != nil {
return nil, fmt.Errorf("set %s: %w", client.ConfigHomeEnv, err)
}

return func() error { return os.RemoveAll(dir) }, nil
}

// saveConnection saves remote and key as shard setup would, under a configuration directory of the test's own.
func saveConnection(t *testing.T, remote, key string) {
t.Helper()

t.Setenv(client.ConfigHomeEnv, t.TempDir())
path, err := client.ConfigPath(os.Getenv)
if err != nil {
t.Fatalf("ConfigPath: %v", err)
}
if err := client.SaveConfig(path, client.Config{Remote: remote, APIKey: key}); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
}

// A saved connection names the server with no flag and no SHARD_REMOTE or SHARD_API_KEY, and the verb goes there, not to the socket. (SHARD-657)
func TestASavedConnectionReachesTheFront(t *testing.T) {
var out bytes.Buffer

app, f, _ := newFrontApp(t, &out)
noRemoteEnv(t)
t.Setenv(client.CAFileEnv, f.ca)
saveConnection(t, f.url, f.key)
// The front still serves the daemon of the old root; the CLI gets a root with no socket at all.
app.Root = t.TempDir()

if err := app.Run(t.Context(), []string{"list"}); err != nil {
t.Fatalf("list through the saved connection: %v", err)
}
if !strings.Contains(out.String(), "up-1") {
t.Errorf("list through the saved connection printed %q, want the sandbox the daemon holds", out.String())
}
}

// An explicit empty --remote asks for the socket, so the saved server sees no dial and the saved file is not read. (SHARD-657)
func TestAnEmptyRemoteFlagIgnoresTheSavedConnection(t *testing.T) {
var out bytes.Buffer
accepted := acceptCount(t)

app := newListApp(t, &out, listed(), nil)
noRemoteEnv(t)
saveConnection(t, "https://"+accepted.address, leakKey)

if err := app.Run(t.Context(), []string{"--remote", "", "list"}); err != nil {
t.Fatalf("list over the socket: %v", err)
}
if !strings.Contains(out.String(), "up-1") {
t.Errorf("list over the socket printed %q, want the sandbox the daemon holds", out.String())
}
accepted.none(t)
}

// A host verb would report this host as the saved server, so it refuses before it dials or touches the root, and never prints the key. (SHARD-657)
func TestAHostVerbRefusesASavedConnection(t *testing.T) {
accepted := acceptCount(t)

noRemoteEnv(t)
saveConnection(t, "https://"+accepted.address, leakKey)
for verb, args := range map[string][]string{
"pull": {"pull", "alpine:3.20"},
"image list": {"image", "list"},
"daemon status": {"daemon", "status"},
"info": {"info"},
"tokens mint": {"tokens", "mint", "--name", "ci"},
"tokens list": {"tokens", "list"},
} {
root := t.TempDir()
app := App{Version: "test", Root: root, Out: io.Discard}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
err := app.Run(ctx, args)
cancel()
if want := "shard " + verb + " runs on the daemon host only"; err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "saved connection") {
t.Errorf("%s with a saved connection returned %v, want %q and the saved connection", verb, err, want)
}
if err != nil && strings.Contains(err.Error(), leakKey) {
t.Errorf("%s printed the saved key", verb)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read the root: %v", err)
}
if len(entries) != 0 {
t.Errorf("%s with a saved connection left %d entries in the root, want none", verb, len(entries))
}
}

accepted.none(t)
}

// The saved connection says where commands go, never where a front runs, so serve fails for its own reason alone. (SHARD-657)
func TestServeIgnoresTheSavedConnection(t *testing.T) {
accepted := acceptCount(t)

noRemoteEnv(t)
saveConnection(t, "https://"+accepted.address, leakKey)
missing := filepath.Join(t.TempDir(), "missing-signing-key")
app := App{Version: "test", Root: t.TempDir(), Out: io.Discard}

err := app.Run(t.Context(), []string{"serve", "--listen", "127.0.0.1:0", "--signing-key-file", missing})
if err == nil || strings.Contains(err.Error(), "daemon host only") || !strings.Contains(err.Error(), missing) {
t.Errorf("serve with a saved connection returned %v, want the missing signing key", err)
}
accepted.none(t)
}

// A saved file no client can use stops every verb with its path, rather than a quiet fall back to the socket. (SHARD-657)
func TestABrokenSavedConnectionNamesTheFile(t *testing.T) {
var out bytes.Buffer

app := newListApp(t, &out, listed(), nil)
noRemoteEnv(t)
t.Setenv(client.ConfigHomeEnv, t.TempDir())
path, err := client.ConfigPath(os.Getenv)
if err != nil {
t.Fatalf("ConfigPath: %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("make the configuration directory: %v", err)
}
if err := os.WriteFile(path, []byte(`{"remote":"ftp://shard.example.com","api_key":"`+leakKey+`"}`), 0o600); err != nil {
t.Fatalf("write the configuration: %v", err)
}

err = app.Run(t.Context(), []string{"list"})
if err == nil || !strings.Contains(err.Error(), path) || strings.Contains(err.Error(), leakKey) {
t.Errorf("list with a broken saved connection returned %v, want its path and never the key", err)
}
if strings.Contains(out.String(), "up-1") {
t.Error("list with a broken saved connection read the socket")
}
}
2 changes: 1 addition & 1 deletion cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (a App) daemon(ctx context.Context, args []string) error {
if flags.NArg() != 0 {
return fmt.Errorf("daemon takes no arguments, or status, got %s", gotArgs(flags.Args()))
}
if err := a.hostOnly("daemon"); err != nil {
if err := a.noRemote("daemon"); err != nil {
return err
}

Expand Down
Loading
Loading