// Package instanceexec holds the three programs that run INSIDE an isolated // instance's namespace, as carlos, launched by the template units of the // isolated-exec design: the trampoline (instance-exec, §6.2), the advisory // exit-record writer (instance-exit, §6.5) and the readiness probe // (instance-probe, §8.5). None of them is for operators; each is an // ExecStart= or ExecStopPost= of a unit. package instanceexec import ( "encoding/json" "errors" "flag" "fmt" "log" "os" "path/filepath" "strings" "github.com/carlosframework/platform/internal/cliflag" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/instancefs" "github.com/carlosframework/platform/internal/provision" ) // LiveDir is where the adoption pass keeps each host's live symlink; the // trampoline execs nothing that is not directly under it. const LiveDir = "/opt/carlos/live" var errSymlinkOrEscape = errors.New("instance-exec: launch file refused") // dataDir is the agent's own resolver (host/agent.go), repeated here because // this program runs in a namespace where only the instance's directory is // bound, and it must agree with the agent on where that directory is. func dataDir() string { if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { return d } return "/data" } // RunExec is `carlos instance-exec `. func RunExec(args []string) { fs := flag.NewFlagSet("instance-exec", flag.ExitOnError) fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exec ") claimSnapshot := fs.String("claim-reservation", "", "root launch reservation") _ = fs.Parse(args) if fs.NArg() != 2 { fs.Usage() os.Exit(2) } role, ok := instancectl.ParseRole(fs.Arg(0)) if !ok { log.Fatalf("instance-exec: role %q is not run, seed, sidecar or cmd", fs.Arg(0)) } // The tenant is scoped (#178, see ScopeAbstractUnix) and runs under // the trampoline as its init (#126, see init_linux.go). launch := scopedInit if *claimSnapshot != "" { if *claimSnapshot != instancectl.ClaimExecSnapshot || role != instancectl.RoleRun || strings.Contains(fs.Arg(1), ":") { log.Fatal("instance-exec: claim boundary refused") } launch = func(argv0 string, argv, env []string) error { return scopedClaimInit(argv0, argv, env, *claimSnapshot) } } if err := execLaunch(dataDir(), role, fs.Arg(1), launch); err != nil { log.Fatalf("instance-exec: %v", err) } } // execLaunch reads launch..json from the instance directory through // instancefs (never through a symlink), validates it, and launches. It // returns only on refusal; a successful launch exits with the tenant. // // The validation is defence in depth: the agent wrote the file, but the // directory is the instance's own, so the instance can rewrite it. What // the checks guarantee is that whatever is executed is directly under // LiveDir (the platform's live links, root-written) and named for the host // in the file. The file's host must also match the instance the unit was // started for, so a rewritten file can execute only what the tenant could // execute anyway: its own binary with an argv and env it chose. func execLaunch(dataDir string, role instancectl.Role, inst string, execFn func(argv0 string, argv, env []string) error) error { iid, launchName, err := launchTarget(role, inst) if err != nil { return err } dir := filepath.Join(dataDir, "instances", iid) raw, err := instancefs.ReadBeneath(dir, launchName) if err != nil { if errors.Is(err, instancefs.ErrSymlink) || errors.Is(err, instancefs.ErrEscapes) { return fmt.Errorf("%w: %w", errSymlinkOrEscape, err) } return err } var l instancectl.Launch if err := json.Unmarshal(raw, &l); err != nil { return fmt.Errorf("launch file: %v", err) } if l.Schema != instancectl.LaunchSchema { return fmt.Errorf("launch file schema %d is not %d", l.Schema, instancectl.LaunchSchema) } if !provision.ValidHostShape(l.Host) { return fmt.Errorf("launch file host %q is not a host", l.Host) } if provision.InstanceID(l.Host) != iid { return fmt.Errorf("launch file host %q is not instance %s", l.Host, iid) } if len(l.Argv) == 0 { return errors.New("launch file has no argv") } argv0 := l.Argv[0] if !filepath.IsAbs(argv0) || filepath.Clean(argv0) != argv0 || filepath.Dir(argv0) != LiveDir { return fmt.Errorf("argv[0] %q is not directly under %s", argv0, LiveDir) } if filepath.Base(argv0) != l.Host { return fmt.Errorf("argv[0] %q is not the live link of host %s", argv0, l.Host) } for _, kv := range l.Env { if k, _, ok := strings.Cut(kv, "="); !ok || k == "" { return fmt.Errorf("launch file env entry %q is not KEY=value", kv) } } return execFn(argv0, l.Argv, l.Env) } // launchTarget is the trampoline's split of the unit's instance string // (zero-downtime spec §7.6): "" for the three original templates, // ":" for the generation template, whose launch file is keyed by // generation. Only a run unit has generations. func launchTarget(role instancectl.Role, inst string) (iid, name string, err error) { if !strings.Contains(inst, ":") { return inst, instancectl.LaunchFileName(role), nil } iid, n, err := splitGeneration(role, inst) if err != nil { return "", "", err } return iid, instancectl.GenLaunchFileName(n), nil } // recordTarget is launchTarget for the exit record. func recordTarget(role instancectl.Role, inst, invocation string) (iid, name string, err error) { if !strings.Contains(inst, ":") { return inst, instancectl.ExitRecordName(role, invocation), nil } iid, n, err := splitGeneration(role, inst) if err != nil { return "", "", err } return iid, instancectl.GenExitRecordName(n, invocation), nil } func splitGeneration(role instancectl.Role, inst string) (string, uint64, error) { if role != instancectl.RoleRun { return "", 0, fmt.Errorf("instance %q: only a run unit has generations", inst) } iid, n, ok := instancectl.ParseGenInstance(inst) if !ok { return "", 0, fmt.Errorf("instance %q is not :", inst) } return iid, n, nil }