| 1 | package runs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/instancefs" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | // StageFiles makes <instanceDir>/.carlos-run/<id>/ and downloads the run's |
| 18 | // files into it (run spec §5.2 step 3). Both directories are created |
| 19 | // beneath the instance's own directory, 0700, without following a symlink. |
| 20 | // A directory that is already there is accepted as it is: the tenant shares |
| 21 | // the agent's uid so its mode and owner prove nothing, the run id is |
| 22 | // console-minted and unguessable, and a collision only denies the tenant's |
| 23 | // own run. Anything else at either name (a symlink, a file, a FIFO) is a |
| 24 | // refusal. Each file is created O_EXCL 0600 through instancefs, so a name |
| 25 | // the tenant planted first is a refusal, never a write through it, and is |
| 26 | // streamed from the store through a limit of its recorded size plus one |
| 27 | // byte, so the read is bounded however large the object is; its length and |
| 28 | // sha256 are checked against request.json as it is written, and its name |
| 29 | // against PlainName. The directory exists even when there are no files: it |
| 30 | // is CARLOS_RUN_DIR. |
| 31 | // |
| 32 | // When a file fails after the run directory is in place, the directory is |
| 33 | // removed again (no symlink followed), so a refused staging leaves nothing |
| 34 | // half-written; a failure to remove it is joined onto the error returned. |
| 35 | func StageFiles(ctx context.Context, s release.Store, instanceDir string, req Request) (string, error) { |
| 36 | if !ValidID(req.ID) { |
| 37 | return "", fmt.Errorf("runs: %q is not a run id", req.ID) |
| 38 | } |
| 39 | root := filepath.Join(instanceDir, DirName) |
| 40 | if _, err := instancefs.EnsureDirBeneath(instanceDir, DirName, 0o700); err != nil { |
| 41 | return "", err |
| 42 | } |
| 43 | if _, err := instancefs.EnsureDirBeneath(root, req.ID, 0o700); err != nil { |
| 44 | return "", err |
| 45 | } |
| 46 | dir := filepath.Join(root, req.ID) |
| 47 | if err := stageEach(ctx, s, dir, req); err != nil { |
| 48 | if rerr := instancefs.RemoveAllBeneath(root, req.ID); rerr != nil { |
| 49 | err = errors.Join(err, rerr) |
| 50 | } |
| 51 | return "", err |
| 52 | } |
| 53 | return dir, nil |
| 54 | } |
| 55 | |
| 56 | // stageOne streams one input into dir/f.Name: created first, so a planted |
| 57 | // name is refused before anything is fetched, then copied through a limit |
| 58 | // of f.Size+1 into the file and a hasher at once. A length or hash that is |
| 59 | // not the recorded one is an error; the caller removes the run directory. |
| 60 | func stageOne(ctx context.Context, s release.Store, dir string, req Request, f File) error { |
| 61 | out, err := instancefs.OpenBeneath(dir, f.Name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 62 | if err != nil { |
| 63 | return err |
| 64 | } |
| 65 | defer out.Close() |
| 66 | rc, _, err := release.OpenObject(ctx, s, InputKey(req.Account, req.App, req.ID, f.Name)) |
| 67 | if err != nil { |
| 68 | return fmt.Errorf("runs: read %s: %w", f.Name, err) |
| 69 | } |
| 70 | defer rc.Close() |
| 71 | h := sha256.New() |
| 72 | n, err := io.Copy(io.MultiWriter(out, h), io.LimitReader(rc, f.Size+1)) |
| 73 | if err != nil { |
| 74 | return fmt.Errorf("runs: read %s: %w", f.Name, err) |
| 75 | } |
| 76 | if n != f.Size || hex.EncodeToString(h.Sum(nil)) != f.SHA256 { |
| 77 | return fmt.Errorf("runs: %s does not match its size and sha256", f.Name) |
| 78 | } |
| 79 | return out.Close() |
| 80 | } |
| 81 | |
| 82 | func stageEach(ctx context.Context, s release.Store, dir string, req Request) error { |
| 83 | for _, f := range req.Files { |
| 84 | if !PlainName(f.Name) { |
| 85 | return fmt.Errorf("runs: %q is not a plain file name", f.Name) |
| 86 | } |
| 87 | if err := stageOne(ctx, s, dir, req, f); err != nil { |
| 88 | return err |
| 89 | } |
| 90 | } |
| 91 | return nil |
| 92 | } |
| 93 | |
| 94 | // RemoveRunDir deletes one run's directory, following no symlink. A tenant |
| 95 | // can make a tree uncleanable (by depth or mode): the caller logs the error |
| 96 | // and carries on, it never fails a run. |
| 97 | func RemoveRunDir(instanceDir, id string) error { |
| 98 | if !ValidID(id) { |
| 99 | return fmt.Errorf("runs: %q is not a run id", id) |
| 100 | } |
| 101 | return instancefs.RemoveAllBeneath(filepath.Join(instanceDir, DirName), id) |
| 102 | } |
| 103 | |
| 104 | // SweepRunDirs deletes every instance's .carlos-run directory (run spec |
| 105 | // §5.8, the box sweep). It runs at agent start, before any run is picked: |
| 106 | // the edge's stop took every cmd unit with it (BindsTo=carlos-edge.service), |
| 107 | // so no run on this box is open. One instance it cannot clean (a tenant can |
| 108 | // make a tree uncleanable) does not stop it: it moves on to the next and |
| 109 | // returns every failure joined, for the caller to log. |
| 110 | func SweepRunDirs(dataDir string) error { |
| 111 | base := filepath.Join(dataDir, "instances") |
| 112 | ents, err := os.ReadDir(base) |
| 113 | if errors.Is(err, os.ErrNotExist) { |
| 114 | return nil |
| 115 | } |
| 116 | if err != nil { |
| 117 | return err |
| 118 | } |
| 119 | var errs []error |
| 120 | for _, e := range ents { |
| 121 | if !e.IsDir() { |
| 122 | continue |
| 123 | } |
| 124 | if err := instancefs.RemoveAllBeneath(filepath.Join(base, e.Name()), DirName); err != nil { |
| 125 | errs = append(errs, err) |
| 126 | } |
| 127 | } |
| 128 | return errors.Join(errs...) |
| 129 | } |
| 130 | |