This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / engines / nix / engine_test.go
14 kB 374 lines
1package nix 2 3import ( 4 "bytes" 5 "context" 6 "io" 7 "os" 8 "path/filepath" 9 "strings" 10 "testing" 11 12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/require" 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/spindle/config" 16 "tangled.org/core/spindle/models" 17 "tangled.org/core/workflow" 18) 19 20func TestInitWorkflow(t *testing.T) { 21 e := &Engine{cfg: &config.Config{}} 22 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: "{}"}, tangled.Pipeline{}) 23 require.NoError(t, err) 24 require.Len(t, wf.Steps, 1) 25 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 26 assert.Equal(t, models.StepKindSystem, wf.Steps[0].Kind()) 27} 28 29func TestInitWorkflowCompiledProductionWorkflow(t *testing.T) { 30 e := &Engine{cfg: &config.Config{}} 31 raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n" 32 compiled := (&workflow.Compiler{}).Compile([]workflow.Workflow{{ 33 Name: ".tangled/workflows/ci.yml", 34 Engine: "nix", 35 Raw: raw, 36 }}) 37 require.Len(t, compiled.Workflows, 1) 38 require.Equal(t, raw, compiled.Workflows[0].Raw) 39 40 wf, err := e.InitWorkflow(*compiled.Workflows[0], tangled.Pipeline{}) 41 require.NoError(t, err) 42 require.Len(t, wf.Steps, 1) 43 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 44} 45 46func TestInitWorkflowRejectsCustomSteps(t *testing.T) { 47 e := &Engine{cfg: &config.Config{}} 48 _, err := e.InitWorkflow(tangled.Pipeline_Workflow{ 49 Name: "flake", 50 Raw: "steps:\n - name: nope\n command: echo nope\n", 51 }, tangled.Pipeline{}) 52 require.Error(t, err) 53 assert.Contains(t, err.Error(), "unknown field") 54} 55 56// a generic workflow document (engine/when/clone, no engine-specific keys) 57// must initialize, only structural keys like `steps` are rejected 58func TestInitWorkflowAcceptsGenericKeys(t *testing.T) { 59 e := &Engine{cfg: &config.Config{}} 60 raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n depth: 1\n" 61 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: raw}, tangled.Pipeline{}) 62 require.NoError(t, err) 63 require.Len(t, wf.Steps, 1) 64 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 65 66 _, err = e.InitWorkflow(tangled.Pipeline_Workflow{ 67 Name: "flake", 68 Raw: raw + "steps:\n - name: nope\n command: echo nope\n", 69 }, tangled.Pipeline{}) 70 require.Error(t, err) 71 assert.Contains(t, err.Error(), "unknown field") 72} 73 74// every eval/metadata invocation needs the flakes features flags because the 75// sandbox HOME has no nix.conf 76func TestEvalArgv(t *testing.T) { 77 e := &Engine{cfg: &config.Config{}} 78 exe, argv := e.evalArgv([]string{"flake", "metadata", "--json", "."}) 79 assert.Equal(t, containerPrlimit, exe) 80 require.GreaterOrEqual(t, len(argv), 6) 81 assert.True(t, strings.HasPrefix(argv[0], "--as=")) 82 assert.Equal(t, containerNix, argv[1]) 83 assert.Equal(t, "--extra-experimental-features", argv[2]) 84 assert.Equal(t, "nix-command flakes", argv[3]) 85 assert.Equal(t, []string{"flake", "metadata", "--json", "."}, argv[4:]) 86} 87 88func TestNixStringLit(t *testing.T) { 89 cases := map[string]string{ 90 "plain": `"plain"`, 91 "dots.and%percent": `"dots.and%percent"`, 92 `quote"injection`: `"quote\"injection"`, 93 `interp${pwn}`: `"interp\${pwn}"`, 94 `back\slash`: `"back\\slash"`, 95 `"; throw "escaped`: `"\"; throw \"escaped"`, 96 "newline\ninside": "\"newline\ninside\"", // literal newlines are legal in nix strings 97 } 98 for in, want := range cases { 99 assert.Equal(t, want, nixStringLit(in), "input %q", in) 100 } 101} 102 103// metacharacter attr names stay structured: the installable is only the 104// trusted category.system parent, the apply lambda sees just the escaped 105// name, display is for humans 106func TestOutputCandidateMetachars(t *testing.T) { 107 cand := outputCandidate{ 108 kind: targetSystemOutput, 109 category: "checks", 110 system: "x86_64-linux", 111 name: `unit.100%"cov${throw}`, 112 } 113 installable, apply, err := cand.drvTarget() 114 require.NoError(t, err) 115 assert.Equal(t, ".#checks.x86_64-linux", installable) 116 assert.Contains(t, apply, nixStringLit(cand.name)) 117 assert.NotContains(t, apply, cand.name) 118 assert.NotContains(t, installable, cand.name) 119 assert.Equal(t, `.#checks.x86_64-linux.unit.100%"cov${throw}`, cand.display()) 120 121 _, _, err = outputCandidate{kind: targetKind(99), name: "x"}.drvTarget() 122 require.Error(t, err) 123} 124 125// only validated system strings may reach an installable, anything else is 126// refused before it becomes argv 127func TestParentInstallable(t *testing.T) { 128 installable, err := parentInstallable("packages", "x86_64-linux") 129 require.NoError(t, err) 130 assert.Equal(t, ".#packages.x86_64-linux", installable) 131 132 installable, err = parentInstallable("nixosConfigurations", "") 133 require.NoError(t, err) 134 assert.Equal(t, ".#nixosConfigurations", installable) 135 136 for _, system := range []string{ 137 `x86_64-linux"; throw "`, 138 "x86_64-linux ${throw 1}", 139 "x86_64-linux --option", 140 "x86_64-linux/x", 141 "x86_64 linux", 142 } { 143 _, err := parentInstallable("packages", system) 144 require.Error(t, err, "system %q", system) 145 assert.Contains(t, err.Error(), "unsafe system") 146 } 147} 148 149// a flake that doesn't define a probed category is "absent", not broken; 150// other evaluation failures must not be swallowed 151func TestIsMissingAttrError(t *testing.T) { 152 // verbatim nix 2.x output for `nix eval .#checks.x86_64-linux` on a flake 153 // without checks 154 assert.True(t, isMissingAttrError( 155 "error: flake 'path:/workdir' does not provide attribute 'packages.x86_64-linux.checks.x86_64-linux', 'legacyPackages.x86_64-linux.checks.x86_64-linux' or 'checks.x86_64-linux'")) 156 // older nix phrasing 157 assert.True(t, isMissingAttrError("error: attribute 'homeConfigurations' missing")) 158 159 assert.False(t, isMissingAttrError("error: syntax error, unexpected end of file")) 160 assert.False(t, isMissingAttrError("error: flake output is not a derivation")) 161 assert.False(t, isMissingAttrError("")) 162} 163 164func TestCheckOutputLimit(t *testing.T) { 165 require.NoError(t, checkOutputLimit(0, 100)) 166 require.NoError(t, checkOutputLimit(100, 100)) 167 err := checkOutputLimit(101, 100) 168 require.Error(t, err) 169 assert.Contains(t, err.Error(), "101") 170 assert.Contains(t, err.Error(), "100") 171} 172 173func TestMaxOutputsDefault(t *testing.T) { 174 assert.Equal(t, 100, (&Engine{cfg: &config.Config{}}).maxOutputs()) 175 assert.Equal(t, 3, (&Engine{cfg: &config.Config{NixPipelines: config.NixPipelines{MaxOutputs: 3}}}).maxOutputs()) 176} 177 178func TestMaxLogWriterOverflow(t *testing.T) { 179 var buf bytes.Buffer 180 canceled := false 181 mw := &maxLogWriter{limit: 10, cancel: func() { canceled = true }} 182 w := &limitedStreamWriter{parent: mw, out: &buf} 183 n, err := w.Write([]byte("0123456789")) 184 require.NoError(t, err) 185 require.Equal(t, 10, n) 186 assert.False(t, mw.exceeded) 187 188 n, err = w.Write([]byte("overflow")) 189 require.Error(t, err) 190 assert.Equal(t, 0, n) 191 assert.Contains(t, err.Error(), "exceeded the 10 byte limit") 192 assert.True(t, mw.exceeded) 193 assert.True(t, canceled) 194 assert.Equal(t, "0123456789", buf.String()) 195 196 _, err = w.Write([]byte("more")) 197 require.Error(t, err) 198 assert.Equal(t, "0123456789", buf.String()) 199} 200 201func TestMaxLogWriterTruncatesPartialWrite(t *testing.T) { 202 var buf bytes.Buffer 203 mw := &maxLogWriter{limit: 5} 204 w := &limitedStreamWriter{parent: mw, out: &buf} 205 n, err := w.Write([]byte("0123456789")) 206 require.Error(t, err) 207 assert.Equal(t, 5, n) 208 assert.Equal(t, "01234", buf.String()) 209 assert.True(t, mw.exceeded) 210} 211 212func TestAuthorizePathInput(t *testing.T) { 213 workspace := t.TempDir() 214 outside := t.TempDir() 215 216 require.NoError(t, os.MkdirAll(filepath.Join(workspace, "sub", "dir"), 0o755)) 217 require.NoError(t, authorizePathInput("a", map[string]any{"path": "sub/dir"}, workspace)) 218 require.NoError(t, authorizePathInput("b", map[string]any{"path": "not/there/yet"}, workspace)) 219 require.NoError(t, authorizePathInput("c", map[string]any{"path": "."}, workspace)) 220 221 require.Error(t, authorizePathInput("d", map[string]any{"path": "/etc/passwd"}, workspace)) 222 require.Error(t, authorizePathInput("e", map[string]any{"path": "../outside"}, workspace)) 223 require.Error(t, authorizePathInput("f", map[string]any{"path": "sub/../../outside"}, workspace)) 224 225 require.Error(t, authorizePathInput("g", map[string]any{}, workspace)) 226 227 require.NoError(t, os.Symlink(filepath.Join(workspace, "sub"), filepath.Join(workspace, "inner-link"))) 228 require.NoError(t, authorizePathInput("h", map[string]any{"path": "inner-link/dir"}, workspace)) 229 230 // a lexically-inside path can still escape through a symlink 231 require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "escape"))) 232 err := authorizePathInput("i", map[string]any{"path": "escape"}, workspace) 233 require.Error(t, err) 234 assert.Contains(t, err.Error(), "symlink") 235 236 require.NoError(t, os.MkdirAll(filepath.Join(workspace, "deep"), 0o755)) 237 require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "deep", "escape"))) 238 require.Error(t, authorizePathInput("j", map[string]any{"path": "deep/escape"}, workspace)) 239} 240 241func TestInitWorkflowWithClone(t *testing.T) { 242 e := &Engine{cfg: &config.Config{Server: config.Server{Dev: true}}} 243 repoName := "my-repo" 244 repoDid := "did:plc:repo" 245 tpl := tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ 246 Kind: string(workflow.TriggerKindPush), 247 Push: &tangled.Pipeline_PushTriggerData{ 248 Ref: "refs/heads/main", 249 NewSha: "1234567890abcdef1234567890abcdef12345678", 250 }, 251 Repo: &tangled.Pipeline_TriggerRepo{ 252 Knot: "example.com", Did: "did:plc:owner", Repo: &repoName, RepoDid: &repoDid, 253 }, 254 }} 255 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tpl) 256 require.NoError(t, err) 257 require.Len(t, wf.Steps, 2) 258 assert.IsType(t, models.CloneStep{}, wf.Steps[0]) 259 assert.Equal(t, "Evaluate flake outputs", wf.Steps[1].Name()) 260} 261 262func TestBaseBwrapArgs(t *testing.T) { 263 e := &Engine{ 264 nixBinPath: "/nix/store/nix/bin/nix", 265 bashBinPath: "/nix/store/bash/bin/bash", 266 gitBinPath: "/nix/store/git/bin/git", 267 prlimitBinPath: "/nix/store/prlimit/bin/prlimit", 268 } 269 args := e.baseBwrapArgs(addlFields{ 270 workspaceDir: "/tmp/host/workspace", 271 homeDir: "/tmp/host/home", 272 }) 273 argString := strings.Join(args, " ") 274 for _, expected := range []string{ 275 "--die-with-parent", "--new-session", "--unshare-all --share-net", 276 "--ro-bind /nix/store /nix/store", 277 "--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket", 278 "--proc /proc", "--dev /dev", "--tmpfs /tmp", 279 "--bind /tmp/host/workspace /workdir", "--bind /tmp/host/home /home", 280 "--symlink /nix/store/nix/bin/nix /usr/bin/nix", 281 "--symlink /nix/store/bash/bin/bash /usr/bin/bash", 282 "--symlink /nix/store/bash/bin/bash /bin/sh", 283 "--symlink /nix/store/git/bin/git /usr/bin/git", 284 "--symlink /nix/store/prlimit/bin/prlimit /usr/bin/prlimit", 285 "--chdir /workdir", 286 } { 287 assert.Contains(t, argString, expected) 288 } 289 assert.NotContains(t, argString, "nixpkgs#") 290 assert.NotContains(t, argString, " shell ") 291} 292 293type mockWorkflowLogger struct { 294 stdout strings.Builder 295 stderr strings.Builder 296} 297 298func (m *mockWorkflowLogger) Close() error { return nil } 299func (m *mockWorkflowLogger) DataWriter(_ int, stream string) io.Writer { 300 if stream == "stderr" { 301 return &m.stderr 302 } 303 return &m.stdout 304} 305func (m *mockWorkflowLogger) ControlWriter(_ int, _ models.Step, _ models.StepStatus) io.Writer { 306 return io.Discard 307} 308 309func TestIntegrationRealFlakeBuilds(t *testing.T) { 310 if os.Getenv("RUN_NIX_INTEGRATION_TEST") != "true" { 311 t.Skip("set RUN_NIX_INTEGRATION_TEST=true to run") 312 } 313 314 cfg := &config.Config{NixPipelines: config.NixPipelines{WorkDirBase: t.TempDir()}} 315 e, err := New(cfg) 316 require.NoError(t, err) 317 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tangled.Pipeline{}) 318 require.NoError(t, err) 319 wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"}, Name: "flake"} 320 logger := &mockWorkflowLogger{} 321 require.NoError(t, e.SetupWorkflow(context.Background(), wid, wf, logger)) 322 defer e.DestroyWorkflow(context.Background(), wid) 323 324 addl := wf.Data.(addlFields) 325 flake := `{ 326 inputs.nixpkgs.url = "nixpkgs"; 327 outputs = { nixpkgs, ... }: 328 let 329 system = "x86_64-linux"; 330 pkgs = nixpkgs.legacyPackages.${system}; 331 in { 332 packages.${system}.hello = pkgs.runCommand "spindle-package" {} "echo package > $out"; 333 checks.${system}."unit.100%cov" = pkgs.runCommand "spindle-check" {} "echo check > $out"; 334 formatter.${system} = pkgs.hello; 335 devShells.${system}.ci = pkgs.mkShell {}; 336 }; 337} 338` 339 require.NoError(t, os.WriteFile(filepath.Join(addl.workspaceDir, "flake.nix"), []byte(flake), 0o600)) 340 341 // the loop bound is evaluated per iteration, so build steps appended by 342 // the discovery step run in the same pass 343 for idx := 0; idx < len(wf.Steps); idx++ { 344 require.NoError(t, e.RunStep(context.Background(), wid, wf, idx, nil, logger), logger.stderr.String()) 345 } 346 347 require.GreaterOrEqual(t, len(wf.Steps), 5, "discovery + packages/checks/formatter/devShells builds") 348 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) 349 var names []string 350 for _, step := range wf.Steps[1:] { 351 s, ok := step.(Step) 352 require.True(t, ok, "generated step %q is a nix engine Step", step.Name()) 353 require.NotEmpty(t, s.drvPath, s.Name()) 354 assert.Contains(t, s.Command(), "^*", s.Name()) 355 names = append(names, s.Name()) 356 } 357 for _, fragment := range []string{"packages", "checks", "formatter", "devShells", "unit.100%cov"} { 358 assert.Condition(t, func() bool { return containsName(names, fragment) }, fragment) 359 } 360 361 require.NoError(t, e.DestroyWorkflow(context.Background(), wid)) 362 assert.NoDirExists(t, addl.workspaceDir) 363 assert.NoDirExists(t, addl.homeDir) 364 assert.NoError(t, e.DestroyWorkflow(context.Background(), wid)) 365} 366 367func containsName(names []string, fragment string) bool { 368 for _, name := range names { 369 if strings.Contains(name, fragment) { 370 return true 371 } 372 } 373 return false 374}