|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "strings" |
| 7 | + "testing" |
| 8 | + |
| 9 | + "github.com/devcontainers/cli/internal/config" |
| 10 | + "github.com/devcontainers/cli/internal/docker" |
| 11 | +) |
| 12 | + |
| 13 | +// mountOrString builds a config.MountOrString from a JSON literal (its internal |
| 14 | +// representation is unexported, so this also exercises the unmarshal path). |
| 15 | +func mountOrString(t *testing.T, jsonLit string) config.MountOrString { |
| 16 | + t.Helper() |
| 17 | + var m config.MountOrString |
| 18 | + if err := json.Unmarshal([]byte(jsonLit), &m); err != nil { |
| 19 | + t.Fatal(err) |
| 20 | + } |
| 21 | + return m |
| 22 | +} |
| 23 | + |
| 24 | +// TestMetadataMounts_ReadonlyObject covers upstream #881: a top-level mount |
| 25 | +// OBJECT with readonly:true must carry the flag through so both the docker and |
| 26 | +// compose renderers emit a read-only mount (previously the struct dropped it). |
| 27 | +func TestMetadataMounts_ReadonlyObject(t *testing.T) { |
| 28 | + cfg := &config.DevContainer{ |
| 29 | + Mounts: []config.MountOrString{ |
| 30 | + mountOrString(t, `{"type":"bind","source":"/local","target":"/remote","readonly":true}`), |
| 31 | + }, |
| 32 | + } |
| 33 | + entries := metadataMounts(cfg) |
| 34 | + if len(entries) != 1 { |
| 35 | + t.Fatalf("want 1 mount entry, got %d", len(entries)) |
| 36 | + } |
| 37 | + m, ok := entries[0].(map[string]interface{}) |
| 38 | + if !ok { |
| 39 | + t.Fatalf("mount entry is %T, want map", entries[0]) |
| 40 | + } |
| 41 | + if ro, _ := m["readonly"].(bool); !ro { |
| 42 | + t.Fatalf("#881 readonly not serialized: %v", m) |
| 43 | + } |
| 44 | + |
| 45 | + // docker path: CreateContainerArgs must render `readonly` in the --mount spec. |
| 46 | + mounts, err := mountsFromMetadata(entries, "devid") |
| 47 | + if err != nil { |
| 48 | + t.Fatal(err) |
| 49 | + } |
| 50 | + args := docker.CreateContainerArgs("img", nil, nil, mounts, "", nil, nil, nil, nil, false, nil, nil) |
| 51 | + if !strings.Contains(strings.Join(args, " "), "target=/remote,readonly") { |
| 52 | + t.Errorf("#881 docker --mount missing readonly: %v", args) |
| 53 | + } |
| 54 | + |
| 55 | + // compose path: the volume spec must end in :ro. |
| 56 | + specs, _, err := composeVolumeSpecsFromMetadata(entries, "devid") |
| 57 | + if err != nil { |
| 58 | + t.Fatal(err) |
| 59 | + } |
| 60 | + if len(specs) != 1 || !strings.HasSuffix(specs[0], ":ro") { |
| 61 | + t.Errorf("#881 compose spec missing :ro: %v", specs) |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +// TestMetadataMounts_ReadonlyOmittedByDefault guards that a mount without the |
| 66 | +// property does not spuriously become read-only. |
| 67 | +func TestMetadataMounts_ReadonlyOmittedByDefault(t *testing.T) { |
| 68 | + cfg := &config.DevContainer{ |
| 69 | + Mounts: []config.MountOrString{ |
| 70 | + mountOrString(t, `{"type":"bind","source":"/local","target":"/remote"}`), |
| 71 | + }, |
| 72 | + } |
| 73 | + m := metadataMounts(cfg)[0].(map[string]interface{}) |
| 74 | + if _, present := m["readonly"]; present { |
| 75 | + t.Errorf("#881 readonly must be omitted when unset: %v", m) |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +// TestCheckGPUAvailability_NvidiaCLIProbe covers upstream #319: when the nvidia |
| 80 | +// runtime is reported by docker info but nvidia-container-cli reports NO GPU |
| 81 | +// (driver loaded, no device), detect mode must NOT enable the GPU. |
| 82 | +func TestCheckGPUAvailability_NvidiaCLIProbe(t *testing.T) { |
| 83 | + // checkGPUAvailability's detect path needs a docker info result; without a |
| 84 | + // real daemon we can only exercise the "all"/"none" shortcuts here plus the |
| 85 | + // probe override in isolation. Verify the probe override is honored by the |
| 86 | + // detect branch's decision function. |
| 87 | + orig := nvidiaContainerCLIInfo |
| 88 | + t.Cleanup(func() { nvidiaContainerCLIInfo = orig }) |
| 89 | + ctx := context.Background() |
| 90 | + |
| 91 | + // Tool present, no GPU -> the refined signal wins (false). |
| 92 | + nvidiaContainerCLIInfo = func(context.Context) (bool, bool) { return true, false } |
| 93 | + if detectGPU(ctx, true) { |
| 94 | + t.Error("#319 GPU enabled despite nvidia-container-cli reporting no GPU") |
| 95 | + } |
| 96 | + // Tool present, GPU present -> true. |
| 97 | + nvidiaContainerCLIInfo = func(context.Context) (bool, bool) { return true, true } |
| 98 | + if !detectGPU(ctx, true) { |
| 99 | + t.Error("#319 GPU not enabled despite a present GPU") |
| 100 | + } |
| 101 | + // Tool absent -> fall back to the docker-info runtime signal (TS parity). |
| 102 | + nvidiaContainerCLIInfo = func(context.Context) (bool, bool) { return false, false } |
| 103 | + if !detectGPU(ctx, true) { |
| 104 | + t.Error("#319 without nvidia-container-cli, must keep TS runtime-only behavior") |
| 105 | + } |
| 106 | + if detectGPU(ctx, false) { |
| 107 | + t.Error("no nvidia runtime in docker info -> GPU must stay disabled") |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +// TestBuildPositionalPath covers upstream #8: the advertised [path] positional |
| 112 | +// is honored as the workspace folder when --workspace-folder is not set. |
| 113 | +func TestBuildPositionalPath(t *testing.T) { |
| 114 | + cmd := newBuildCmd() |
| 115 | + if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { |
| 116 | + t.Error("#8 build must reject more than one positional arg") |
| 117 | + } |
| 118 | + if err := cmd.Args(cmd, []string{"only"}); err != nil { |
| 119 | + t.Errorf("#8 build must accept a single positional arg: %v", err) |
| 120 | + } |
| 121 | +} |
0 commit comments