| 1 | // Package instanceexec holds the three programs that run INSIDE an isolated |
| 2 | // instance's namespace, as carlos, launched by the template units of the |
| 3 | // isolated-exec design: the trampoline (instance-exec, §6.2), the advisory |
| 4 | // exit-record writer (instance-exit, §6.5) and the readiness probe |
| 5 | // (instance-probe, §8.5). None of them is for operators; each is an |
| 6 | // ExecStart= or ExecStopPost= of a unit. |
| 7 | package instanceexec |
| 8 | |
| 9 | import ( |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "flag" |
| 13 | "fmt" |
| 14 | "log" |
| 15 | "os" |
| 16 | "path/filepath" |
| 17 | "strings" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/cliflag" |
| 20 | "github.com/carlosframework/platform/internal/instancectl" |
| 21 | "github.com/carlosframework/platform/internal/instancefs" |
| 22 | "github.com/carlosframework/platform/internal/provision" |
| 23 | ) |
| 24 | |
| 25 | // LiveDir is where the adoption pass keeps each host's live symlink; the |
| 26 | // trampoline execs nothing that is not directly under it. |
| 27 | const LiveDir = "/opt/carlos/live" |
| 28 | |
| 29 | var errSymlinkOrEscape = errors.New("instance-exec: launch file refused") |
| 30 | |
| 31 | // dataDir is the agent's own resolver (host/agent.go), repeated here because |
| 32 | // this program runs in a namespace where only the instance's directory is |
| 33 | // bound, and it must agree with the agent on where that directory is. |
| 34 | func dataDir() string { |
| 35 | if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { |
| 36 | return d |
| 37 | } |
| 38 | return "/data" |
| 39 | } |
| 40 | |
| 41 | // RunExec is `carlos instance-exec <role> <iid>`. |
| 42 | func RunExec(args []string) { |
| 43 | fs := flag.NewFlagSet("instance-exec", flag.ExitOnError) |
| 44 | fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exec <run|seed|sidecar|cmd> <instance-id>") |
| 45 | claimSnapshot := fs.String("claim-reservation", "", "root launch reservation") |
| 46 | _ = fs.Parse(args) |
| 47 | if fs.NArg() != 2 { |
| 48 | fs.Usage() |
| 49 | os.Exit(2) |
| 50 | } |
| 51 | role, ok := instancectl.ParseRole(fs.Arg(0)) |
| 52 | if !ok { |
| 53 | log.Fatalf("instance-exec: role %q is not run, seed, sidecar or cmd", fs.Arg(0)) |
| 54 | } |
| 55 | // The tenant is scoped (#178, see ScopeAbstractUnix) and runs under |
| 56 | // the trampoline as its init (#126, see init_linux.go). |
| 57 | launch := scopedInit |
| 58 | if *claimSnapshot != "" { |
| 59 | if *claimSnapshot != instancectl.ClaimExecSnapshot || role != instancectl.RoleRun || strings.Contains(fs.Arg(1), ":") { |
| 60 | log.Fatal("instance-exec: claim boundary refused") |
| 61 | } |
| 62 | launch = func(argv0 string, argv, env []string) error { return scopedClaimInit(argv0, argv, env, *claimSnapshot) } |
| 63 | } |
| 64 | if err := execLaunch(dataDir(), role, fs.Arg(1), launch); err != nil { |
| 65 | log.Fatalf("instance-exec: %v", err) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // execLaunch reads launch.<role>.json from the instance directory through |
| 70 | // instancefs (never through a symlink), validates it, and launches. It |
| 71 | // returns only on refusal; a successful launch exits with the tenant. |
| 72 | // |
| 73 | // The validation is defence in depth: the agent wrote the file, but the |
| 74 | // directory is the instance's own, so the instance can rewrite it. What |
| 75 | // the checks guarantee is that whatever is executed is directly under |
| 76 | // LiveDir (the platform's live links, root-written) and named for the host |
| 77 | // in the file. The file's host must also match the instance the unit was |
| 78 | // started for, so a rewritten file can execute only what the tenant could |
| 79 | // execute anyway: its own binary with an argv and env it chose. |
| 80 | func execLaunch(dataDir string, role instancectl.Role, inst string, execFn func(argv0 string, argv, env []string) error) error { |
| 81 | iid, launchName, err := launchTarget(role, inst) |
| 82 | if err != nil { |
| 83 | return err |
| 84 | } |
| 85 | dir := filepath.Join(dataDir, "instances", iid) |
| 86 | raw, err := instancefs.ReadBeneath(dir, launchName) |
| 87 | if err != nil { |
| 88 | if errors.Is(err, instancefs.ErrSymlink) || errors.Is(err, instancefs.ErrEscapes) { |
| 89 | return fmt.Errorf("%w: %w", errSymlinkOrEscape, err) |
| 90 | } |
| 91 | return err |
| 92 | } |
| 93 | var l instancectl.Launch |
| 94 | if err := json.Unmarshal(raw, &l); err != nil { |
| 95 | return fmt.Errorf("launch file: %v", err) |
| 96 | } |
| 97 | if l.Schema != instancectl.LaunchSchema { |
| 98 | return fmt.Errorf("launch file schema %d is not %d", l.Schema, instancectl.LaunchSchema) |
| 99 | } |
| 100 | if !provision.ValidHostShape(l.Host) { |
| 101 | return fmt.Errorf("launch file host %q is not a host", l.Host) |
| 102 | } |
| 103 | if provision.InstanceID(l.Host) != iid { |
| 104 | return fmt.Errorf("launch file host %q is not instance %s", l.Host, iid) |
| 105 | } |
| 106 | if len(l.Argv) == 0 { |
| 107 | return errors.New("launch file has no argv") |
| 108 | } |
| 109 | argv0 := l.Argv[0] |
| 110 | if !filepath.IsAbs(argv0) || filepath.Clean(argv0) != argv0 || filepath.Dir(argv0) != LiveDir { |
| 111 | return fmt.Errorf("argv[0] %q is not directly under %s", argv0, LiveDir) |
| 112 | } |
| 113 | if filepath.Base(argv0) != l.Host { |
| 114 | return fmt.Errorf("argv[0] %q is not the live link of host %s", argv0, l.Host) |
| 115 | } |
| 116 | for _, kv := range l.Env { |
| 117 | if k, _, ok := strings.Cut(kv, "="); !ok || k == "" { |
| 118 | return fmt.Errorf("launch file env entry %q is not KEY=value", kv) |
| 119 | } |
| 120 | } |
| 121 | return execFn(argv0, l.Argv, l.Env) |
| 122 | } |
| 123 | |
| 124 | // launchTarget is the trampoline's split of the unit's instance string |
| 125 | // (zero-downtime spec §7.6): "<iid>" for the three original templates, |
| 126 | // "<iid>:<n>" for the generation template, whose launch file is keyed by |
| 127 | // generation. Only a run unit has generations. |
| 128 | func launchTarget(role instancectl.Role, inst string) (iid, name string, err error) { |
| 129 | if !strings.Contains(inst, ":") { |
| 130 | return inst, instancectl.LaunchFileName(role), nil |
| 131 | } |
| 132 | iid, n, err := splitGeneration(role, inst) |
| 133 | if err != nil { |
| 134 | return "", "", err |
| 135 | } |
| 136 | return iid, instancectl.GenLaunchFileName(n), nil |
| 137 | } |
| 138 | |
| 139 | // recordTarget is launchTarget for the exit record. |
| 140 | func recordTarget(role instancectl.Role, inst, invocation string) (iid, name string, err error) { |
| 141 | if !strings.Contains(inst, ":") { |
| 142 | return inst, instancectl.ExitRecordName(role, invocation), nil |
| 143 | } |
| 144 | iid, n, err := splitGeneration(role, inst) |
| 145 | if err != nil { |
| 146 | return "", "", err |
| 147 | } |
| 148 | return iid, instancectl.GenExitRecordName(n, invocation), nil |
| 149 | } |
| 150 | |
| 151 | func splitGeneration(role instancectl.Role, inst string) (string, uint64, error) { |
| 152 | if role != instancectl.RoleRun { |
| 153 | return "", 0, fmt.Errorf("instance %q: only a run unit has generations", inst) |
| 154 | } |
| 155 | iid, n, ok := instancectl.ParseGenInstance(inst) |
| 156 | if !ok { |
| 157 | return "", 0, fmt.Errorf("instance %q is not <instance-id>:<generation>", inst) |
| 158 | } |
| 159 | return iid, n, nil |
| 160 | } |
| 161 | |