Skip to content
Draft
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
49 changes: 49 additions & 0 deletions docs/CLI-ADAPTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,55 @@ cli:
force_args: [--daemonize, "no"]
```

## What a call leaves running outside the app — `cli.teardown`, `cli.env_rules`

Some CLIs hand what they start to a process outside the adapter's process
group and return: `smolvm machine start` and `machine run -d` leave a VM
running in its own group, reparented to init. Nothing in the group-based
lifecycle (clean stop, child guard, Pdeathsig, the supervisor's group stop)
reaches it. Declare the command that undoes such a call:

```yaml
- name: smol.exec
cli:
passthrough: true
teardown:
- argv_prefix: [machine, start] # which calls start something
name_flags: [--name, -n] # --name x / --name=x names it
default_name: default # no flag: this name
run: [machine, stop, --name, "${name}"] # argv after the base command
clear_prefixes: [[machine, stop], [machine, delete]]
- argv_prefix: [machine, run]
when_flags: [-d, --detach] # only a detached run
name_flags: [--name, -n]
default_name: default
run: [machine, stop, --name, "${name}"]
clear_prefixes: [[machine, stop], [machine, delete]]
```

A matching call opens a record (before the CLI runs, so a start cut short
by a stop is still covered). A clean stop of the app runs every open record's
command (30 s each, concurrently); if the app is SIGKILLed, the child guard,
which stays up while any record is open, runs them before it takes the group
down. A successful call under `clear_prefixes` for the same name closes the
record, and a start that exits non-zero closes the record it opened. Flags
are only looked for before a `--`.

`env_rules` add environment to the child of one kind of call. smol uses it
to arm smolvm's own parent-death watchdog for an ephemeral VM, since
`machine run` tears its VM down only on SIGINT, and SIGTERM or SIGKILL of the
CLI orphans it:

```yaml
env_rules:
- argv_prefix: [machine, run]
unless_flags: [-d, --detach] # a detached VM must outlive its CLI
env: {SMOLVM_BOOT_BINARY: "${command_dir}/smolvm-bin"}
```

`${command_dir}` is the directory of the resolved base command (the staged
binary's directory for an app with assets).

## Hardening built into the runner

The generated `exec.go` is defensive by default:
Expand Down
25 changes: 24 additions & 1 deletion internal/publish/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ import (
// primary (the bare bundle_url older clients fetch).
var DefaultPlatforms = []string{"linux/amd64", "linux/arm64", "darwin/arm64", "darwin/amd64"}

// BuildPlatforms is the subset of DefaultPlatforms cfg can run on. An app that
// stages native assets (install.json) runs only where it has an asset: on any
// other platform its adapter builds, but exits on every spawn with "no asset
// for <os>/<arch>". io.pilot.smol 1.2.0 shipped such a darwin/amd64 bundle
// (upstream smolvm has no darwin x86_64 build), which crash-looped. Apps with
// no assets run everywhere the pure-Go adapter compiles for.
func BuildPlatforms(cfg *scaffold.Config) []string {
if len(cfg.Assets) == 0 {
return DefaultPlatforms
}
have := map[string]bool{}
for _, a := range cfg.Assets {
have[a.OS+"/"+a.Arch] = true
}
var out []string
for _, p := range DefaultPlatforms {
if have[p] {
out = append(out, p)
}
}
return out
}

// PlatformBundle is one OS/arch's signed tarball.
type PlatformBundle struct {
Platform string // "darwin/arm64"
Expand Down Expand Up @@ -106,7 +129,7 @@ func BuildBundle(cfg *scaffold.Config, priv ed25519.PrivateKey) (*Bundle, error)
primaryBinBytes int
primaryTarBytes int
)
for _, plat := range DefaultPlatforms {
for _, plat := range BuildPlatforms(cfg) {
pb, binLen, err := buildPlatform(tmp, cfg, mfRaw, priv, plat)
if err != nil {
return nil, fmt.Errorf("build %s: %w", plat, err)
Expand Down
20 changes: 20 additions & 0 deletions internal/publish/build_platforms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import (
"bytes"
"compress/gzip"
"io"
"strings"
"testing"

"github.com/pilot-protocol/app-template/internal/scaffold"
)

// magicForPlatform returns the executable magic an os should produce.
Expand Down Expand Up @@ -97,3 +100,20 @@ func TestBuildBundle_AllPlatforms(t *testing.T) {
t.Errorf("missing platforms: %v", want)
}
}

// TestBuildPlatforms_AssetsLimitTargets: an app that stages native assets is
// built only for the platforms it has an asset for (io.pilot.smol 1.2.0
// shipped a darwin/amd64 bundle with no smolvm for it, which exited on every
// spawn). An app without assets builds for every default platform.
func TestBuildPlatforms_AssetsLimitTargets(t *testing.T) {
cfg := sampleSubmission().ToConfig()
if got := BuildPlatforms(cfg); len(got) != len(DefaultPlatforms) {
t.Fatalf("no assets: platforms = %v, want %v", got, DefaultPlatforms)
}
cfg.Assets = []scaffold.Asset{{OS: "darwin", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "linux", Arch: "arm64"}}
got := BuildPlatforms(cfg)
want := []string{"linux/amd64", "linux/arm64", "darwin/arm64"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("platforms = %v, want %v", got, want)
}
}
5 changes: 5 additions & 0 deletions internal/publish/submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ type SubCLIRoute struct {
// Service marks a method that starts a long-running server the adapter
// owns and stops with it (see scaffold.CLIService).
Service *SubCLIService `json:"service,omitempty"`
// EnvRules / Teardown mirror scaffold.CLIRoute (passthrough only).
EnvRules []scaffold.CLIEnvRule `json:"env_rules,omitempty"`
Teardown []scaffold.CLITeardown `json:"teardown,omitempty"`
}

// SubCLIService mirrors scaffold.CLIService.
Expand Down Expand Up @@ -732,6 +735,8 @@ func (s Submission) ToConfig() *scaffold.Config {
ParamsAsFlags: m.CLI.ParamsAsFlags,
Passthrough: m.CLI.Passthrough,
Tools: m.CLI.Tools,
EnvRules: m.CLI.EnvRules,
Teardown: m.CLI.Teardown,
}
if sv := m.CLI.Service; sv != nil {
method.CLI.Service = &scaffold.CLIService{
Expand Down
162 changes: 162 additions & 0 deletions internal/scaffold/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,93 @@ type CLIRoute struct {
// Service marks the method as starting a long-running server that the
// adapter owns (see CLIService), instead of a run-to-completion command.
Service *CLIService `yaml:"service,omitempty"`
// EnvRules (passthrough only) add environment variables to the CLI child
// of a call whose argv matches (see CLIEnvRule).
EnvRules []CLIEnvRule `yaml:"env_rules,omitempty"`
// Teardown (passthrough only) records what a call started that lives on
// after the CLI exits (a detached VM, a background daemon the CLI itself
// manages) and the command that stops it. The adapter runs that command
// when it stops, and its child guard runs it if the adapter is killed.
Teardown []CLITeardown `yaml:"teardown,omitempty"`
}

// CLIEnvRule adds Env to the child of a passthrough call whose argv starts
// with ArgvPrefix and carries none of UnlessFlags (before a "--"). Values may
// use ${command_dir}: the directory of the resolved base command (the staged
// binary's directory for an app with assets).
//
// Written for io.pilot.smol: `smolvm machine run` (an ephemeral VM) tears its
// VM down only on SIGINT. SIGTERM or SIGKILL of the CLI (a call timeout, the
// adapter's stop, the child guard) orphaned the VM, which runs in its own
// process group. With SMOLVM_BOOT_BINARY set, smolvm arms a parent-death
// watchdog in the VM process, so the VM dies with its CLI whatever signal hits
// it. A detached run (-d) must not get it, or its VM would die as soon as the
// CLI returns.
type CLIEnvRule struct {
ArgvPrefix []string `yaml:"argv_prefix" json:"argv_prefix"`
UnlessFlags []string `yaml:"unless_flags,omitempty" json:"unless_flags,omitempty"`
Env map[string]string `yaml:"env" json:"env"`
}

// CLITeardown declares that a passthrough call whose argv starts with
// ArgvPrefix (and, with WhenFlags, carries one of them) leaves something
// running that the app owns. The resource is named by the value of the first
// of NameFlags the call passes (`--name x` or `--name=x`), else DefaultName.
// Run is the argv (after the base command) that stops it, with ${name}. A
// successful call whose argv starts with one of ClearPrefixes and resolves to
// the same name (an explicit stop or delete) drops the record.
//
// Written for io.pilot.smol: `machine start` and `machine run -d` leave a VM
// running after the CLI exits, in its own process group and outside the
// adapter's reach; it survived the app's SIGTERM, the supervisor's SIGKILL and
// uninstall. Each is now stopped with `machine stop --name <name>`.
type CLITeardown struct {
ArgvPrefix []string `yaml:"argv_prefix" json:"argv_prefix"`
WhenFlags []string `yaml:"when_flags,omitempty" json:"when_flags,omitempty"`
NameFlags []string `yaml:"name_flags,omitempty" json:"name_flags,omitempty"`
DefaultName string `yaml:"default_name,omitempty" json:"default_name,omitempty"`
Run []string `yaml:"run" json:"run"`
ClearPrefixes [][]string `yaml:"clear_prefixes,omitempty" json:"clear_prefixes,omitempty"`
}

// LifecycleGo renders the route's env rules and teardown rules as Spec
// fields (a leading ", " each), for the generated registerHandlers.
func (r *CLIRoute) LifecycleGo() string {
if r == nil {
return ""
}
var b strings.Builder
if len(r.EnvRules) > 0 {
b.WriteString(", EnvRules: []backend.EnvRule{")
for i, er := range r.EnvRules {
if i > 0 {
b.WriteString(", ")
}
keys := make([]string, 0, len(er.Env))
for k := range er.Env {
keys = append(keys, k)
}
sort.Strings(keys)
kv := make([]string, 0, 2*len(keys))
for _, k := range keys {
kv = append(kv, k, er.Env[k])
}
fmt.Fprintf(&b, "{Prefix: %#v, Unless: %#v, Env: %#v}", er.ArgvPrefix, er.UnlessFlags, kv)
}
b.WriteString("}")
}
if len(r.Teardown) > 0 {
b.WriteString(", Teardown: []backend.TeardownRule{")
for i, td := range r.Teardown {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "{Prefix: %#v, When: %#v, NameFlags: %#v, DefaultName: %q, Run: %#v, Clear: %#v}",
td.ArgvPrefix, td.WhenFlags, td.NameFlags, td.DefaultName, td.Run, td.ClearPrefixes)
}
b.WriteString("}")
}
return b.String()
}

// CLIService declares a method that starts a long-running server. The adapter
Expand Down Expand Up @@ -1595,6 +1682,7 @@ func (c *Config) validateCLIMethod(i int, m Method) []error {
if sv := m.CLI.Service; sv != nil {
errs = append(errs, validateCLIService(i, m, sv)...)
}
errs = append(errs, validateCLILifecycle(i, m)...)
return errs
}

Expand Down Expand Up @@ -1863,3 +1951,77 @@ func isLoopbackHost(host string) bool {
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

var envNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

// validateCLILifecycle checks cli.env_rules and cli.teardown (see CLIEnvRule,
// CLITeardown).
func validateCLILifecycle(i int, m Method) []error {
var errs []error
c := m.CLI
if (len(c.EnvRules) > 0 || len(c.Teardown) > 0) && !c.Passthrough {
errs = append(errs, fmt.Errorf("methods[%d] (%s): cli.env_rules/cli.teardown only apply to a passthrough route", i, m.Name))
}
flagsOK := func(at string, flags []string) {
for _, f := range flags {
if !strings.HasPrefix(f, "-") || f == "-" || f == "--" || strings.ContainsAny(f, "= ") {
errs = append(errs, fmt.Errorf("%s: %q must be a flag such as --detach or -d", at, f))
}
}
}
prefixOK := func(at string, p []string) {
if len(p) == 0 {
errs = append(errs, fmt.Errorf("%s: argv_prefix must not be empty", at))
}
for _, a := range p {
if a == "" || strings.HasPrefix(a, "-") {
errs = append(errs, fmt.Errorf("%s: argv_prefix entry %q must be a subcommand word", at, a))
}
}
}
for j, er := range c.EnvRules {
at := fmt.Sprintf("methods[%d] (%s): cli.env_rules[%d]", i, m.Name, j)
prefixOK(at, er.ArgvPrefix)
flagsOK(at+".unless_flags", er.UnlessFlags)
if len(er.Env) == 0 {
errs = append(errs, fmt.Errorf("%s: env must not be empty", at))
}
for k, v := range er.Env {
if !envNameRE.MatchString(k) {
errs = append(errs, fmt.Errorf("%s: env name %q is not a valid variable name", at, k))
}
for _, tok := range placeholderREScaffold.FindAllString(v, -1) {
if tok != "${command_dir}" {
errs = append(errs, fmt.Errorf("%s: env %s uses %s; only ${command_dir} is available", at, k, tok))
}
}
}
}
for j, td := range c.Teardown {
at := fmt.Sprintf("methods[%d] (%s): cli.teardown[%d]", i, m.Name, j)
prefixOK(at, td.ArgvPrefix)
flagsOK(at+".when_flags", td.WhenFlags)
flagsOK(at+".name_flags", td.NameFlags)
if len(td.Run) == 0 {
errs = append(errs, fmt.Errorf("%s: run (the argv that stops what the call started) must not be empty", at))
}
usesName := false
for _, a := range td.Run {
for _, tok := range placeholderREScaffold.FindAllString(a, -1) {
if tok != "${name}" {
errs = append(errs, fmt.Errorf("%s: run uses %s; only ${name} is available", at, tok))
}
usesName = true
}
}
if usesName && len(td.NameFlags) == 0 && td.DefaultName == "" {
errs = append(errs, fmt.Errorf("%s: run uses ${name} but there is no name_flags or default_name to fill it", at))
}
for _, cp := range td.ClearPrefixes {
prefixOK(at+".clear_prefixes", cp)
}
}
return errs
}

var placeholderREScaffold = regexp.MustCompile(`\$\{[^}]*\}`)
1 change: 1 addition & 0 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func childProcFiles() []file {
{filepath.Join("internal", "backend", "childproc_other.go"), "childproc_other.go.tmpl"},
{filepath.Join("internal", "backend", "childguard.go"), "childguard.go.tmpl"},
{filepath.Join("internal", "backend", "service.go"), "service.go.tmpl"},
{filepath.Join("internal", "backend", "teardown.go"), "teardown.go.tmpl"},
{filepath.Join("internal", "backend", "svcproc_linux.go"), "svcproc_linux.go.tmpl"},
{filepath.Join("internal", "backend", "svcproc_other.go"), "svcproc_other.go.tmpl"},
}
Expand Down
Loading
Loading