├── .envrc ├── .github ├── dependabot.yml └── workflows │ └── update-flake-lock.yml ├── .gitignore ├── .golangci.yml ├── .mergify.yml ├── LICENSE ├── README.org ├── bors.toml ├── client.go ├── client_test.go ├── daemon.go ├── default.nix ├── flake.lock ├── flake.nix ├── go.mod ├── main.go ├── message.go ├── module.nix ├── overlay.nix ├── shell.nix ├── systemd.go └── tests ├── default.nix ├── multipleHosts.nix └── simple.nix /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: gomod 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/update-flake-lock.yml: -------------------------------------------------------------------------------- 1 | name: update-flake-lock 2 | on: 3 | workflow_dispatch: # allows manual triggering 4 | schedule: 5 | - cron: '0 0 * * 1,4' # Run twice a week 6 | 7 | jobs: 8 | lockfile: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v5 13 | - name: Install Nix 14 | uses: cachix/install-nix-action@v31 15 | - name: Update flake.lock 16 | uses: DeterminateSystems/update-flake-lock@v27 17 | with: 18 | pr-labels: | 19 | merge-queue 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | queued-build-hook 2 | .pre-commit-config.yaml 3 | .direnv 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # options for analysis running 3 | run: 4 | deadline: 20m 5 | issues-exit-code: 1 6 | tests: true 7 | 8 | # output configuration options 9 | output: 10 | format: colored-line-number 11 | print-issued-lines: true 12 | print-linter-name: true 13 | 14 | # linters that we should / shouldn't run 15 | linters: 16 | enable-all: true 17 | disable: 18 | - golint 19 | - varcheck 20 | - ifshort 21 | - interfacer 22 | - structcheck 23 | - exhaustivestruct 24 | - scopelint 25 | - deadcode 26 | - nosnakecase 27 | - maligned 28 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: default 3 | queue_conditions: 4 | - base=master 5 | - label~=merge-queue|dependencies 6 | merge_conditions: 7 | - check-success=Evaluate flake.nix 8 | - check-success=check multipleHosts [x86_64-linux] 9 | - check-success=check pre-commit-check [x86_64-linux] 10 | - check-success=check shell [x86_64-linux] 11 | - check-success=check simple [x86_64-linux] 12 | - check-success=devShell default [x86_64-linux] 13 | - check-success=package default [x86_64-linux] 14 | - check-success=package queued-build-hook [x86_64-linux] 15 | merge_method: rebase 16 | 17 | pull_request_rules: 18 | - name: merge using the merge queue 19 | conditions: [] 20 | actions: 21 | queue: 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 adisbladis and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | * queued-build-hook - a Nix post-build-hook with some superpowers 2 | 3 | This is a simple client/daemon combination that allows configurable retries & async queueing of post-build-hooks. 4 | 5 | ** Hacking 6 | 7 | Start daemon 8 | #+begin_src sh 9 | go build 10 | rm -f testsock 11 | systemfd -s unix::./testsock -- ./queued-build-hook daemon --hook ./realhook.sh 12 | #+end_src 13 | 14 | Run client 15 | #+begin_src sh 16 | go build 17 | ./queued-build-hook queue --socket ./testsock 18 | #+end_src 19 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | cut_body_after = "" # don't include text from the PR body in the merge commit message 2 | status = [ 3 | "Evaluate flake.nix", 4 | "devShell default [x86_64-linux]", 5 | "package default [x86_64-linux]", 6 | "package queued-build-hook [x86_64-linux]" 7 | ] 8 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "net" 6 | "os" 7 | "time" 8 | ) 9 | 10 | func runClient(sock string, m interface{}) error { 11 | b, err := EncodeMessage(m) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | didFail := func(l []error) bool { 17 | if len(l) > 10 { 18 | return true 19 | } else { 20 | return false 21 | } 22 | } 23 | conn, errs := tryConnect("unix", sock, expBackoff(2, 100*time.Millisecond), []error{}, didFail) 24 | 25 | if errs != nil { 26 | return errs[0] 27 | } 28 | 29 | unixConn := conn.(*net.UnixConn) 30 | defer unixConn.Close() 31 | 32 | // Write the message and send EOF. 33 | _, err = unixConn.Write(b) 34 | if err != nil { 35 | return err 36 | } 37 | if err := unixConn.CloseWrite(); err != nil { 38 | return err 39 | } 40 | 41 | // Wait for remote to close the connection. 42 | _, err = unixConn.Read([]byte{0}) 43 | if err != nil && err.Error() != "EOF" { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func tryConnect(network, addr string, retryDelay func([]error) time.Duration, prevErrors []error, hasFailed func([]error) bool) (net.Conn, []error) { 51 | if hasFailed(prevErrors) { 52 | return nil, prevErrors 53 | } else { 54 | conn, conn_err := net.Dial("unix", addr) 55 | if conn != nil { 56 | return conn, nil 57 | } 58 | errors := append(prevErrors, conn_err) 59 | dur := retryDelay(errors) 60 | time.Sleep(dur) 61 | return tryConnect(network, addr, retryDelay, errors, hasFailed) 62 | } 63 | return nil, nil 64 | } 65 | 66 | func RunQueueClient(sock string, tag string) error { 67 | return runClient(sock, &QueueMessage{ 68 | DrvPath: os.Getenv("DRV_PATH"), 69 | OutPaths: os.Getenv("OUT_PATHS"), 70 | Tag: tag, 71 | }) 72 | } 73 | 74 | func RunWaitClient(sock string, tag string) error { 75 | return runClient(sock, &WaitMessage{ 76 | Tag: tag, 77 | }) 78 | } 79 | 80 | func expBackoff(factor uint, start time.Duration) func(l []error) time.Duration { 81 | return func(l []error) time.Duration { 82 | n := len(l) 83 | return time.Duration(int(math.Pow(float64(factor), float64(n)))) * start 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestReconnect(t *testing.T) { 9 | type args struct { 10 | sock string 11 | retryDelay func([]error) time.Duration 12 | prevErrors []error 13 | hasFailed func([]error) bool 14 | } 15 | type testCase struct { 16 | setup func() 17 | input args 18 | expect func([]error) bool 19 | teardown func() 20 | } 21 | 22 | cases := []testCase{ 23 | { 24 | setup: func() { 25 | // create UNIX domain socket 26 | }, 27 | input: args{ 28 | sock: "/tmp/enoent", 29 | retryDelay: func(errors []error) time.Duration { 30 | return 1 * time.Second 31 | }, 32 | prevErrors: []error{}, 33 | hasFailed: func(errors []error) bool { 34 | if len(errors) < 20 { 35 | return false 36 | } 37 | return true 38 | }, 39 | }, 40 | expect: func([]error) bool { 41 | return true 42 | }, 43 | teardown: func() { 44 | }, 45 | }, 46 | } 47 | 48 | for _, c := range cases { 49 | _, got := tryConnect("unix", c.input.sock, c.input.retryDelay, c.input.prevErrors, c.input.hasFailed) 50 | 51 | if !c.expect(got) { 52 | panic("got something different than we expected") 53 | } 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /daemon.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net" 8 | "os" 9 | "os/exec" 10 | "time" 11 | ) 12 | 13 | type Waiter struct { 14 | Reply chan struct{} 15 | Tag string 16 | } 17 | 18 | type DaemonConfig struct { 19 | RetryInterval int 20 | Retries int 21 | Concurrency int 22 | } 23 | 24 | func RunDaemon(stderr *log.Logger, realHook string, config *DaemonConfig) { 25 | listeners, err := ListenSystemdFds() 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | if len(listeners) < 1 { 31 | panic("Unexpected number of socket activation fds") 32 | } 33 | 34 | // State variables, accessed only from the main goroutine. 35 | waiters := []*Waiter{} 36 | inProgress := 0 37 | inProgressTags := make(map[string]int) 38 | 39 | // Channels for communicating with the main goroutine. 40 | connections := make(chan net.Conn) 41 | queueRequests := make(chan *QueueMessage) 42 | queueCompleted := make(chan *QueueMessage) 43 | waitRequests := make(chan *Waiter) 44 | 45 | // Channel for communicating with workers. 46 | workerRequests := make(chan *QueueMessage, 256) 47 | 48 | // Execute hook for one request. 49 | execOne := func(m *QueueMessage) { 50 | defer func() { 51 | // Note: the main goroutine may be blocking trying to submit a job to us. 52 | // Send the completion in a new goroutine to prevent blocking here as 53 | // well, which would create a deadlock. 54 | go func() { 55 | queueCompleted <- m 56 | }() 57 | }() 58 | 59 | env := os.Environ() 60 | if m.DrvPath != "" { 61 | env = append(env, fmt.Sprintf("DRV_PATH=%s", m.DrvPath)) 62 | } 63 | if m.OutPaths != "" { 64 | env = append(env, fmt.Sprintf("OUT_PATHS=%s", m.OutPaths)) 65 | } 66 | 67 | msgRetries := config.Retries 68 | for msgRetries != 0 { 69 | cmd := exec.Command(realHook) 70 | cmd.Stdout = os.Stdout 71 | cmd.Stderr = os.Stderr 72 | cmd.Env = env 73 | err := cmd.Run() 74 | if err != nil { 75 | msgRetries -= 1 76 | time.Sleep(time.Duration(config.RetryInterval) * time.Second) 77 | continue 78 | } 79 | return 80 | } 81 | 82 | errorMessage := "Dropped message" 83 | if m.DrvPath != "" { 84 | errorMessage = fmt.Sprintf("%s with DRV_PATH '%s'", errorMessage, m.DrvPath) 85 | } 86 | if m.OutPaths != "" { 87 | errorMessage = fmt.Sprintf("%s with OUT_PATHS '%s'", errorMessage, m.OutPaths) 88 | } 89 | errorMessage = fmt.Sprintf("%s after %d retries", errorMessage, config.Retries) 90 | stderr.Print(errorMessage) 91 | } 92 | 93 | // Worker goroutines. 94 | if config.Concurrency > 0 { 95 | worker := func() { 96 | for { 97 | m := <-workerRequests 98 | execOne(m) 99 | } 100 | } 101 | for i := 0; i < config.Concurrency; i++ { 102 | go worker() 103 | } 104 | } else { 105 | // Infinite concurrency. 106 | go func() { 107 | for { 108 | m := <-workerRequests 109 | go execOne(m) 110 | } 111 | }() 112 | } 113 | 114 | // Launch a goroutine for each listener. 115 | for _, listener := range listeners { 116 | go func(l net.Listener) { 117 | for { 118 | c, err := l.Accept() 119 | if err != nil { 120 | stderr.Print(err) 121 | return 122 | } 123 | connections <- c 124 | } 125 | }(listener) 126 | } 127 | 128 | for { 129 | select { 130 | case c := <-connections: 131 | // Launch a goroutine for each connection. 132 | go func() { 133 | defer c.Close() 134 | 135 | b, err := ioutil.ReadAll(c) 136 | if err != nil { 137 | stderr.Print(err) 138 | return 139 | } 140 | 141 | m, err := DecodeMessage(b) 142 | if err != nil { 143 | stderr.Print(err) 144 | return 145 | } 146 | 147 | switch m := m.(type) { 148 | case *QueueMessage: 149 | queueRequests <- m 150 | case *WaitMessage: 151 | reply := make(chan struct{}) 152 | waitRequests <- &Waiter{ 153 | Reply: reply, 154 | Tag: m.Tag, 155 | } 156 | <-reply 157 | } 158 | }() 159 | 160 | case m := <-queueRequests: 161 | // Forward requests to workers, and track progress. 162 | inProgress += 1 163 | if m.Tag != "" { 164 | n := inProgressTags[m.Tag] 165 | inProgressTags[m.Tag] = n + 1 166 | } 167 | 168 | workerRequests <- m 169 | 170 | case m := <-queueCompleted: 171 | // Scheduler sends us completion. 172 | inProgress -= 1 173 | if inProgress == 0 { 174 | // No jobs at all means there are also no tagged jobs. 175 | for _, waiter := range waiters { 176 | waiter.Reply <- struct{}{} 177 | } 178 | waiters = []*Waiter{} 179 | inProgressTags = make(map[string]int) 180 | 181 | } else if m.Tag != "" { 182 | n := inProgressTags[m.Tag] - 1 183 | if n > 0 { 184 | inProgressTags[m.Tag] = n 185 | } else { 186 | // Reply and remove just waiters for this tag. 187 | delete(inProgressTags, m.Tag) 188 | newWaiters := []*Waiter{} 189 | for _, w := range waiters { 190 | if w.Tag == m.Tag { 191 | w.Reply <- struct{}{} 192 | } else { 193 | newWaiters = append(newWaiters, w) 194 | } 195 | } 196 | waiters = newWaiters 197 | } 198 | } 199 | 200 | case w := <-waitRequests: 201 | // A connection wants to wait on the queue. 202 | if inProgress == 0 { 203 | w.Reply <- struct{}{} 204 | break 205 | } 206 | if w.Tag != "" { 207 | n := inProgressTags[w.Tag] 208 | if n == 0 { 209 | w.Reply <- struct{}{} 210 | break 211 | } 212 | } 213 | waiters = append(waiters, w) 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } 2 | , lib ? pkgs.lib 3 | }: 4 | 5 | pkgs.buildGoModule rec { 6 | name = "queued-build-hook-${version}"; 7 | version = "git"; 8 | 9 | src = lib.cleanSource ./.; 10 | vendorHash = null; 11 | 12 | meta = { 13 | description = "Queue and retry Nix post-build-hook"; 14 | homepage = "https://github.com/nix-community/queued-build-hook"; 15 | license = lib.licenses.mit; 16 | maintainers = [ lib.maintainers.adisbladis ]; 17 | }; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devshell": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1741473158, 11 | "narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=", 12 | "owner": "numtide", 13 | "repo": "devshell", 14 | "rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "numtide", 19 | "repo": "devshell", 20 | "type": "github" 21 | } 22 | }, 23 | "flake-compat": { 24 | "flake": false, 25 | "locked": { 26 | "lastModified": 1747046372, 27 | "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", 28 | "owner": "edolstra", 29 | "repo": "flake-compat", 30 | "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "edolstra", 35 | "repo": "flake-compat", 36 | "type": "github" 37 | } 38 | }, 39 | "flake-utils": { 40 | "inputs": { 41 | "systems": "systems" 42 | }, 43 | "locked": { 44 | "lastModified": 1731533236, 45 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 46 | "owner": "numtide", 47 | "repo": "flake-utils", 48 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "numtide", 53 | "repo": "flake-utils", 54 | "type": "github" 55 | } 56 | }, 57 | "gitignore": { 58 | "inputs": { 59 | "nixpkgs": [ 60 | "pre-commit-hooks", 61 | "nixpkgs" 62 | ] 63 | }, 64 | "locked": { 65 | "lastModified": 1709087332, 66 | "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 67 | "owner": "hercules-ci", 68 | "repo": "gitignore.nix", 69 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "hercules-ci", 74 | "repo": "gitignore.nix", 75 | "type": "github" 76 | } 77 | }, 78 | "nixpkgs": { 79 | "locked": { 80 | "lastModified": 1756819007, 81 | "narHash": "sha256-12V64nKG/O/guxSYnr5/nq1EfqwJCdD2+cIGmhz3nrE=", 82 | "owner": "NixOS", 83 | "repo": "nixpkgs", 84 | "rev": "aaff8c16d7fc04991cac6245bee1baa31f72b1e1", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "id": "nixpkgs", 89 | "ref": "nixpkgs-unstable", 90 | "type": "indirect" 91 | } 92 | }, 93 | "nixpkgs_2": { 94 | "locked": { 95 | "lastModified": 1754340878, 96 | "narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=", 97 | "owner": "NixOS", 98 | "repo": "nixpkgs", 99 | "rev": "cab778239e705082fe97bb4990e0d24c50924c04", 100 | "type": "github" 101 | }, 102 | "original": { 103 | "owner": "NixOS", 104 | "ref": "nixpkgs-unstable", 105 | "repo": "nixpkgs", 106 | "type": "github" 107 | } 108 | }, 109 | "pre-commit-hooks": { 110 | "inputs": { 111 | "flake-compat": "flake-compat", 112 | "gitignore": "gitignore", 113 | "nixpkgs": "nixpkgs_2" 114 | }, 115 | "locked": { 116 | "lastModified": 1755960406, 117 | "narHash": "sha256-RF7j6C1TmSTK9tYWO6CdEMtg6XZaUKcvZwOCD2SICZs=", 118 | "owner": "cachix", 119 | "repo": "pre-commit-hooks.nix", 120 | "rev": "e891a93b193fcaf2fc8012d890dc7f0befe86ec2", 121 | "type": "github" 122 | }, 123 | "original": { 124 | "owner": "cachix", 125 | "repo": "pre-commit-hooks.nix", 126 | "type": "github" 127 | } 128 | }, 129 | "root": { 130 | "inputs": { 131 | "devshell": "devshell", 132 | "flake-utils": "flake-utils", 133 | "nixpkgs": "nixpkgs", 134 | "pre-commit-hooks": "pre-commit-hooks", 135 | "treefmt-nix": "treefmt-nix" 136 | } 137 | }, 138 | "systems": { 139 | "locked": { 140 | "lastModified": 1681028828, 141 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 142 | "owner": "nix-systems", 143 | "repo": "default", 144 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 145 | "type": "github" 146 | }, 147 | "original": { 148 | "owner": "nix-systems", 149 | "repo": "default", 150 | "type": "github" 151 | } 152 | }, 153 | "treefmt-nix": { 154 | "inputs": { 155 | "nixpkgs": [ 156 | "nixpkgs" 157 | ] 158 | }, 159 | "locked": { 160 | "lastModified": 1756662192, 161 | "narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", 162 | "owner": "numtide", 163 | "repo": "treefmt-nix", 164 | "rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", 165 | "type": "github" 166 | }, 167 | "original": { 168 | "owner": "numtide", 169 | "repo": "treefmt-nix", 170 | "type": "github" 171 | } 172 | } 173 | }, 174 | "root": "root", 175 | "version": 7 176 | } 177 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Asynchronous Nix post-build-hook"; 3 | 4 | inputs = { 5 | nixpkgs.url = "nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | devshell = { 8 | url = "github:numtide/devshell"; 9 | inputs = { 10 | nixpkgs.follows = "nixpkgs"; 11 | }; 12 | }; 13 | treefmt-nix = { 14 | url = "github:numtide/treefmt-nix"; 15 | inputs.nixpkgs.follows = "nixpkgs"; 16 | }; 17 | pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix"; 18 | }; 19 | 20 | outputs = { self, nixpkgs, flake-utils, devshell, treefmt-nix, pre-commit-hooks }: 21 | let 22 | supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; 23 | in 24 | { 25 | nixosModules.queued-build-hook = import ./module.nix; 26 | overlays.default = import ./overlay.nix { inherit self; }; 27 | } // flake-utils.lib.eachSystem supportedSystems (system: 28 | let 29 | pkgs = import nixpkgs { 30 | inherit system; 31 | overlays = [ devshell.overlays.default ]; 32 | }; 33 | # treefmt-nix configuration 34 | packages = { 35 | queued-build-hook = pkgs.callPackage ./. { }; 36 | }; 37 | 38 | 39 | treefmt = (treefmt-nix.lib.mkWrapper pkgs 40 | { 41 | projectRootFile = "flake.nix"; 42 | programs = { 43 | nixpkgs-fmt.enable = true; 44 | gofumpt.enable = true; 45 | }; 46 | settings.formatter.deadnix = { 47 | command = "${pkgs.deadnix}/bin/deadnix"; 48 | options = [ "--edit" ]; 49 | includes = [ "*.nix" ]; 50 | }; 51 | }); 52 | in 53 | { 54 | packages = { 55 | queued-build-hook = pkgs.callPackage ./. { }; 56 | }; 57 | 58 | packages.default = self.packages.${system}.queued-build-hook; 59 | 60 | devShells.default = pkgs.devshell.mkShell { 61 | packages = with pkgs; [ 62 | go 63 | golangci-lint 64 | systemfd 65 | treefmt 66 | ]; 67 | devshell.startup.pre-commit.text = self.checks.${system}.pre-commit-check.shellHook; 68 | env = [ 69 | { 70 | name = "DEVSHELL_NO_MOTD"; 71 | value = "1"; 72 | } 73 | ]; 74 | commands = [ 75 | { 76 | name = "fmt"; 77 | help = "Format code"; 78 | command = "${treefmt}/bin/treefmt"; 79 | } 80 | { 81 | name = "check"; 82 | help = "Check the code"; 83 | command = "${pkgs.pre-commit}/bin/pre-commit run --all"; 84 | } 85 | { 86 | name = "lint"; 87 | help = "Lint the code"; 88 | command = "${pkgs.golangci-lint}/bin/golangci-lint run"; 89 | } 90 | ]; 91 | 92 | }; 93 | checks = { 94 | shell = self.devShells.${system}.default; 95 | pre-commit-check = pre-commit-hooks.lib.${system}.run 96 | { 97 | src = ./.; 98 | hooks = { 99 | treefmt-check = 100 | { 101 | enable = true; 102 | entry = "${treefmt}/bin/treefmt --fail-on-change"; 103 | pass_filenames = false; 104 | }; 105 | }; 106 | }; 107 | } // import ./tests { inherit pkgs system; }; 108 | 109 | formatter = treefmt; 110 | 111 | apps.default = { type = "app"; program = "${self.packages.${system}.queued-build-hook}/bin/queued-build-hook"; }; 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nix-community/queued-build-hook 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main // import "github.com/nix-community/queued-build-hook" 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | stderr := log.New(os.Stderr, "queued-build-hook: ", 0) 12 | 13 | daemonCommand := flag.NewFlagSet("daemon", flag.ExitOnError) 14 | realHook := daemonCommand.String("hook", "", "Path to the 'real' post-build-hook") 15 | retryInterval := daemonCommand.Int("retry-interval", 1, "Retry interval (in seconds)") 16 | retries := daemonCommand.Int("retries", 5, "How many retries to attempt before dropping") 17 | concurrency := daemonCommand.Int("concurrency", 0, "How many jobs to run in parallel (default 0 / infinite)") 18 | 19 | queueCommand := flag.NewFlagSet("queue", flag.ExitOnError) 20 | queueSockPath := queueCommand.String("socket", "", "Path to daemon socket") 21 | queueTag := queueCommand.String("tag", "", "Optional tag, for use with wait") 22 | 23 | waitCommand := flag.NewFlagSet("wait", flag.ExitOnError) 24 | waitSockPath := waitCommand.String("socket", "", "Path to daemon socket") 25 | waitTag := waitCommand.String("tag", "", "Optional tag to filter on") 26 | 27 | printDefaults := func() { 28 | fmt.Printf("Usage: \"%s daemon\", \"%s queue\" \"%s wait\"\n", os.Args[0], os.Args[0], os.Args[0]) 29 | 30 | fmt.Println("\nUsage of daemon:") 31 | daemonCommand.PrintDefaults() 32 | 33 | fmt.Println("\nUsage of queue:") 34 | queueCommand.PrintDefaults() 35 | 36 | fmt.Println("\nUsage of wait:") 37 | waitCommand.PrintDefaults() 38 | } 39 | 40 | if len(os.Args) <= 1 { 41 | printDefaults() 42 | os.Exit(1) 43 | } 44 | switch os.Args[1] { 45 | case "daemon": 46 | daemonCommand.Parse(os.Args[2:]) 47 | case "queue": 48 | queueCommand.Parse(os.Args[2:]) 49 | case "wait": 50 | waitCommand.Parse(os.Args[2:]) 51 | } 52 | 53 | if daemonCommand.Parsed() { 54 | hook := *realHook 55 | if hook == "" { 56 | panic("Missing required flag hook") 57 | } 58 | RunDaemon(stderr, hook, &DaemonConfig{ 59 | RetryInterval: *retryInterval, 60 | Retries: *retries, 61 | Concurrency: *concurrency, 62 | }) 63 | 64 | } else if queueCommand.Parsed() { 65 | sock := *queueSockPath 66 | if sock == "" { 67 | panic("Missing required flag socket") 68 | } 69 | 70 | err := RunQueueClient(sock, *queueTag) 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | } else if waitCommand.Parsed() { 76 | sock := *waitSockPath 77 | if sock == "" { 78 | panic("Missing required flag socket") 79 | } 80 | 81 | err := RunWaitClient(sock, *waitTag) 82 | if err != nil { 83 | panic(err) 84 | } 85 | 86 | } else { 87 | printDefaults() 88 | panic("No supported command parsed") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | type envelope struct { 9 | Action string 10 | Payload json.RawMessage 11 | } 12 | 13 | type QueueMessage struct { 14 | DrvPath string `json:"DRV_PATH"` 15 | OutPaths string `json:"OUT_PATHS"` 16 | Tag string `json:"TAG"` 17 | } 18 | 19 | type WaitMessage struct { 20 | Tag string `json:"TAG"` 21 | } 22 | 23 | func EncodeMessage(m interface{}) ([]byte, error) { 24 | switch m := m.(type) { 25 | case *QueueMessage: 26 | b, err := json.Marshal(m) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return json.Marshal(envelope{ 31 | Action: "queue", 32 | Payload: b, 33 | }) 34 | case *WaitMessage: 35 | b, err := json.Marshal(m) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return json.Marshal(envelope{ 40 | Action: "wait", 41 | Payload: b, 42 | }) 43 | default: 44 | return nil, errors.New("invalid message") 45 | } 46 | } 47 | 48 | func DecodeMessage(b []byte) (interface{}, error) { 49 | env := envelope{} 50 | err := json.Unmarshal(b, &env) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | switch env.Action { 56 | case "queue": 57 | m := &QueueMessage{} 58 | err := json.Unmarshal(env.Payload, m) 59 | return m, err 60 | case "wait": 61 | m := &WaitMessage{} 62 | err := json.Unmarshal(env.Payload, m) 63 | return m, err 64 | default: 65 | return nil, errors.New("invalid action") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /module.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | with lib; 3 | let 4 | cfg = config.queued-build-hook; 5 | queued-build-hook = pkgs.callPackage ./. { }; 6 | in 7 | { 8 | options.queued-build-hook = { 9 | enable = mkEnableOption "queued-build-hook"; 10 | 11 | package = mkOption { 12 | type = types.package; 13 | default = queued-build-hook; 14 | description = mdDoc '' 15 | The queued-build-hook package to use. 16 | ''; 17 | }; 18 | 19 | socketDirectory = mkOption { 20 | description = mdDoc '' 21 | Path to store the queued-build-hook daemon's unix socket. 22 | ''; 23 | default = "/var/lib/nix"; 24 | type = types.path; 25 | }; 26 | 27 | socketUser = mkOption { 28 | type = types.str; 29 | example = "user"; 30 | default = "root"; 31 | description = mdDoc '' 32 | This users will have read/write access to the Unix socket. 33 | ''; 34 | }; 35 | 36 | socketGroup = mkOption { 37 | description = mdDoc '' 38 | The users in this group will have read/write access to the Unix socket. 39 | ''; 40 | type = types.str; 41 | default = "nixbld"; 42 | }; 43 | 44 | retryInterval = mkOption { 45 | description = mdDoc '' 46 | The number of seconds between attempts to run the hook for a package after an initial failure. 47 | ''; 48 | type = types.int; 49 | default = 1; 50 | }; 51 | 52 | retries = mkOption { 53 | description = mdDoc '' 54 | The maximum number of attempts that will be made to run the hook for a package before giving up and dropping the task altogether. 55 | ''; 56 | type = types.int; 57 | default = 5; 58 | }; 59 | 60 | concurrency = mkOption { 61 | description = mdDoc '' 62 | Sets the maximum number of tasks that can be executed simultaneously. 63 | By default it is set to 0 which means there is no limit to the number of tasks that can be run concurrently. 64 | ''; 65 | type = types.int; 66 | default = 0; 67 | }; 68 | 69 | enqueueScriptContent = mkOption { 70 | description = mdDoc '' 71 | The script's content responsible for enqueuing newly-built packages and passing them to the daemon. 72 | Although the default configuration should suffice, there may be situations that require customized handling of specific packages. 73 | For example, it may be necessary to process certain packages synchronously using the 'queued-build-hook wait' command, or to ignore certain packages entirely. 74 | ''; 75 | default = '' 76 | ${cfg.package}/bin/queued-build-hook queue --socket "${cfg.socketDirectory}/async-nix-post-build-hook.sock" 77 | ''; 78 | type = types.str; 79 | }; 80 | 81 | postBuildScript = mkOption { 82 | description = mdDoc '' 83 | Specify the path to your postBuildScript 84 | ''; 85 | type = types.nullOr types.path; 86 | default = null; 87 | }; 88 | 89 | postBuildScriptContent = mkOption { 90 | description = mdDoc '' 91 | Specify the content of the script that will manage the newly built package. 92 | The script must be able to handle the OUT_PATHS environment variable, which contains a list of the paths to the newly built packages. 93 | ''; 94 | example = literalExpression '' 95 | exec nix copy --experimental-features nix-command --to "file:///var/nix-cache" $OUT_PATHS 96 | ''; 97 | type = types.nullOr types.str; 98 | default = null; 99 | }; 100 | 101 | credentials = mkOption { 102 | description = mdDoc '' 103 | Credentials to load by startup. Keys that are UPPER_SNAKE will be loaded as env vars. Values are absolute paths to the credentials. 104 | ''; 105 | type = types.attrsOf types.str; 106 | default = { }; 107 | 108 | example = { 109 | AWS_SHARED_CREDENTIALS_FILE = "/run/keys/aws-credentials"; 110 | binary-cache-key = "/run/keys/binary-cache-key"; 111 | }; 112 | }; 113 | 114 | }; 115 | config = mkIf cfg.enable { 116 | 117 | assertions = 118 | [{ 119 | assertion = cfg.postBuildScript != null || cfg.postBuildScriptContent != null; 120 | message = "Either postBuildScript or postBuildScriptContent must be set"; 121 | }]; 122 | 123 | nix.settings.post-build-hook = 124 | let 125 | enqueueScript = pkgs.writeShellScriptBin "enqueue-package" cfg.enqueueScriptContent; 126 | in 127 | "${enqueueScript}/bin/enqueue-package"; 128 | 129 | systemd.sockets = { 130 | async-nix-post-build-hook = { 131 | description = "Async nix post build hooks socket"; 132 | wantedBy = [ "sockets.target" ]; 133 | socketConfig = { 134 | ListenStream = "${cfg.socketDirectory}/async-nix-post-build-hook.sock"; 135 | SocketMode = "0660"; 136 | SocketUser = cfg.socketUser; 137 | SocketGroup = cfg.socketGroup; 138 | Service = "async-nix-post-build-hook.service"; 139 | }; 140 | }; 141 | }; 142 | 143 | systemd.services = 144 | let 145 | hook = if (cfg.postBuildScript != null) then cfg.postBuildScript else (pkgs.writeShellScript "hook" cfg.postBuildScriptContent); 146 | in 147 | { 148 | async-nix-post-build-hook = { 149 | description = "Run nix post build hooks asynchronously"; 150 | wantedBy = [ "multi-user.target" ]; 151 | requires = [ 152 | "async-nix-post-build-hook.socket" 153 | ]; 154 | script = '' 155 | set -euo pipefail 156 | shopt -u nullglob 157 | # Load all credentials into env if they are in UPPER_SNAKE form. 158 | if [[ -n "''${CREDENTIALS_DIRECTORY:-}" ]]; then 159 | for file in "$CREDENTIALS_DIRECTORY"/*; do 160 | key=$(basename "$file") 161 | if [[ $key =~ ^[A-Z0-9_]+$ ]]; then 162 | echo "Environ $key" 163 | export "$key=$(< "$file")" 164 | fi 165 | done 166 | fi 167 | exec ${cfg.package}/bin/queued-build-hook daemon --hook ${hook} --retry-interval ${toString cfg.retryInterval} --retries ${toString cfg.retries} --concurrency ${toString cfg.concurrency} 168 | ''; 169 | environment.HOME = "/var/lib/async-nix-post-build-hook"; 170 | serviceConfig = { 171 | DynamicUser = true; 172 | User = "queued-build-hook"; 173 | Group = "queued-build-hook"; 174 | LoadCredential = mapAttrsToList (key: value: "${key}:${value}") cfg.credentials; 175 | KillMode = "process"; 176 | Restart = "on-failure"; 177 | FileDescriptorStoreMax = 1; 178 | StateDirectory = "async-nix-post-build-hook"; 179 | }; 180 | }; 181 | }; 182 | }; 183 | } 184 | -------------------------------------------------------------------------------- /overlay.nix: -------------------------------------------------------------------------------- 1 | { self }: 2 | _final: prev: { 3 | queued-build-hook = self.packages.${prev.system}.queued-build-hook; 4 | } 5 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | compat = builtins.fetchTarball { 3 | url = "https://github.com/edolstra/flake-compat/archive/b4a34015c698c7793d592d66adbab377907a2be8.tar.gz"; 4 | sha256 = "sha256:1qc703yg0babixi6wshn5wm2kgl5y1drcswgszh4xxzbrwkk9sv7"; 5 | }; 6 | in 7 | (import compat { src = ./.; }).shellNix.default 8 | -------------------------------------------------------------------------------- /systemd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "os" 7 | "strconv" 8 | "syscall" 9 | ) 10 | 11 | // fnctl syscall wrapper 12 | func fcntl(fd int, cmd int, arg int) (int, int) { 13 | r0, _, e1 := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), uintptr(cmd), uintptr(arg)) 14 | return int(r0), int(e1) 15 | } 16 | 17 | // ListenSystemdFds - Listen to FDs provided by systemd 18 | func ListenSystemdFds() ([]net.Listener, error) { 19 | const listenFdsStart = 3 20 | 21 | pid, err := strconv.Atoi(os.Getenv("LISTEN_PID")) 22 | if err != nil || pid != os.Getpid() { 23 | if err == nil { 24 | return nil, err 25 | } else if pid != os.Getpid() { 26 | return nil, errors.New("systemd pid mismatch") 27 | } 28 | } 29 | 30 | nfds, err := strconv.Atoi(os.Getenv("LISTEN_FDS")) 31 | if err != nil || nfds == 0 { 32 | if err == nil { 33 | return nil, err 34 | } else if nfds == 0 { 35 | return nil, errors.New("nfds is zero (could not listen to any provided fds)") 36 | } 37 | } 38 | 39 | listeners := []net.Listener(nil) 40 | for fd := listenFdsStart; fd < listenFdsStart+nfds; fd++ { 41 | flags, errno := fcntl(fd, syscall.F_GETFD, 0) 42 | if errno != 0 { 43 | if errno != 0 { 44 | return nil, syscall.Errno(errno) 45 | } 46 | } 47 | if flags&syscall.FD_CLOEXEC != 0 { 48 | continue 49 | } 50 | syscall.CloseOnExec(fd) 51 | 52 | file := os.NewFile(uintptr(fd), "") 53 | listener, err := net.FileListener(file) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | listeners = append(listeners, listener) 59 | } 60 | 61 | return listeners, nil 62 | } 63 | -------------------------------------------------------------------------------- /tests/default.nix: -------------------------------------------------------------------------------- 1 | { pkgs, system }: 2 | let 3 | lib = pkgs.lib; 4 | 5 | # Find all the nix files defining tests 6 | allTestFiles = 7 | lib.mapAttrs' (filename: _: lib.nameValuePair (lib.removeSuffix ".nix" filename) filename) ( 8 | lib.filterAttrs 9 | (name: _: lib.hasSuffix ".nix" name && name != "default.nix") 10 | (builtins.readDir ./.)); 11 | 12 | mkTest = fileName: import (./. + "/${fileName}") { inherit pkgs system; }; 13 | in 14 | if pkgs.stdenv.isLinux then 15 | lib.mapAttrs (_: mkTest) allTestFiles 16 | else { } 17 | -------------------------------------------------------------------------------- /tests/multipleHosts.nix: -------------------------------------------------------------------------------- 1 | { pkgs, system }: 2 | let 3 | ciPrivateKey = pkgs.writeText "id_ed25519" '' 4 | -----BEGIN OPENSSH PRIVATE KEY----- 5 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 6 | QyNTUxOQAAACCWTaJ1D9Xjxy6759FvQ9oXTes1lmWBciXPkEeqTikBMAAAAJDQBmNV0AZj 7 | VQAAAAtzc2gtZWQyNTUxOQAAACCWTaJ1D9Xjxy6759FvQ9oXTes1lmWBciXPkEeqTikBMA 8 | AAAEDM1IYYFUwk/IVxauha9kuR6bbRtT3gZ6ZA0GLb9txb/pZNonUP1ePHLrvn0W9D2hdN 9 | 6zWWZYFyJc+QR6pOKQEwAAAACGJmb0BtaW5pAQIDBAU= 10 | -----END OPENSSH PRIVATE KEY----- 11 | ''; 12 | 13 | ciPublicKey = pkgs.writeText "id_ed25519.pub" '' 14 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJZNonUP1ePHLrvn0W9D2hdN6zWWZYFyJc+QR6pOKQEw bob@client 15 | ''; 16 | in 17 | pkgs.nixosTest 18 | { 19 | name = "queued-build-hook-multiple-hosts"; 20 | nodes = { 21 | cache = { ... }: { 22 | services.openssh.enable = true; 23 | users.users.root.openssh.authorizedKeys.keyFiles = [ ciPublicKey ]; 24 | }; 25 | ci = { ... }: { 26 | imports = [ ../module.nix ]; 27 | nix.extraOptions = '' 28 | experimental-features = nix-command flakes 29 | ''; 30 | 31 | queued-build-hook = { 32 | enable = true; 33 | credentials = { 34 | ssh-key = builtins.toString ciPrivateKey; 35 | }; 36 | postBuildScriptContent = 37 | let 38 | uploadPathsScript = pkgs.writeShellApplication { 39 | name = "upload-paths"; 40 | runtimeInputs = [ pkgs.nix pkgs.openssh ]; 41 | text = '' 42 | set -euo pipefail 43 | set -x 44 | # This is a dummy post-build-hook that copy over derivation to another host 45 | nix-store --generate-binary-cache-key cache1.example.org /tmp/sk1 /tmp/pk1 46 | nix --extra-experimental-features nix-command store sign --key-file /tmp/sk1 "$OUT_PATHS" 47 | echo "Uploading paths" "$OUT_PATHS" 48 | ls -l "$CREDENTIALS_DIRECTORY" 49 | export NIX_SSHOPTS="-o IdentityFile=''${CREDENTIALS_DIRECTORY}/ssh-key" 50 | exec nix copy --experimental-features nix-command --to "ssh://cache" "$OUT_PATHS" 51 | ''; 52 | }; 53 | in 54 | "${uploadPathsScript}/bin/upload-paths"; 55 | }; 56 | programs.ssh.extraConfig = '' 57 | UserKnownHostsFile /dev/null 58 | Host cache 59 | User root 60 | Hostname cache 61 | IdentityFile ${ciPrivateKey} 62 | StrictHostKeyChecking accept-new 63 | ''; 64 | 65 | system.extraDependencies = with pkgs; [ hello.inputDerivation ]; 66 | }; 67 | 68 | }; 69 | testScript = '' 70 | start_all() 71 | 72 | helloPkg = ci.succeed("nix-instantiate ${pkgs.path} --json --eval -A hello.out.outPath| ${pkgs.jq}/bin/jq -r .").strip() 73 | with subtest("Test copy to another host"): 74 | cache.fail(f"ls -l '{helloPkg}' /nix/store") 75 | ci.succeed("nix-build --option substitute false --no-out-link -A hello ${pkgs.path}") 76 | cache.wait_for_file(f"{helloPkg}") 77 | 78 | with subtest("Already built package is not copied to the other host"): 79 | cache.succeed(f"nix-store --delete {helloPkg}") 80 | cache.fail(f"ls -l '{helloPkg}'") 81 | ci.succeed("nix-build --debug --option substitute false -A hello ${pkgs.path}") 82 | cache.sleep(2) 83 | cache.fail(f"ls -l '{helloPkg}'") 84 | ''; 85 | } 86 | 87 | -------------------------------------------------------------------------------- /tests/simple.nix: -------------------------------------------------------------------------------- 1 | { pkgs, system }: 2 | pkgs.nixosTest 3 | { 4 | name = "queued-build-hook"; 5 | nodes = { 6 | ci = { ... }: { 7 | imports = [ ../module.nix ]; 8 | 9 | queued-build-hook = { 10 | enable = true; 11 | postBuildScriptContent = '' 12 | set -euo pipefail 13 | # This is a dummy post-build-hook that copy over derivation to another directory 14 | echo "Uploading paths" $OUT_PATHS 15 | echo "We can access secret environment variable SECRET_ENV_VAR: ''${SECRET_ENV_VAR}" 16 | echo "We can access secret file secret_file: ''$(cat ''${CREDENTIALS_DIRECTORY}/secret_file)" 17 | # Test Home/XDG directories 18 | mkdir -p $HOME/.tmp 19 | 20 | ${pkgs.nix}/bin/nix-store --generate-binary-cache-key cache1.example.org /tmp/sk1 /tmp/pk1 21 | ${pkgs.nix}/bin/nix --extra-experimental-features nix-command store sign --key-file /tmp/sk1 $OUT_PATHS 22 | exec ${pkgs.nix}/bin/nix copy --experimental-features nix-command --to "file:///var/nix-cache" $OUT_PATHS 23 | ''; 24 | credentials = { 25 | SECRET_ENV_VAR = "/run/keys/secret1"; 26 | secret_file = "/run/keys/secret2"; 27 | }; 28 | }; 29 | 30 | # Grant access to /var/nix-cache for the test 31 | systemd.tmpfiles.rules = [ 32 | "d /var/nix-cache 0777 root - - -" 33 | ]; 34 | systemd.services.async-nix-post-build-hook.serviceConfig.ReadWritePaths = [ "/var/nix-cache" ]; 35 | 36 | # Create dummy secrets - use nix-sops or agenix instead 37 | system.activationScripts.createDummySecrets = '' 38 | echo "Tohgh3Th" > /run/keys/secret1 39 | echo "eQuei0xu" > /run/keys/secret2 40 | ''; 41 | 42 | system.extraDependencies = with pkgs; [ hello.inputDerivation ]; 43 | }; 44 | 45 | }; 46 | testScript = '' 47 | start_all() 48 | ci.succeed("nix-build --no-substitute -A hello '${pkgs.path}'") 49 | # Cache should contain a .narinfo referring to "hello" 50 | 51 | ci.wait_until_succeeds("grep -l 'StorePath: /nix/store/[[:alnum:]]*-hello-.*' /var/nix-cache/*.narinfo") 52 | # Check that the service can access secrets 53 | ci.succeed("journalctl -o cat -u async-nix-post-build-hook.service | grep 'We can access secret environment variable SECRET_ENV_VAR: Tohgh3Th'") 54 | ci.succeed("journalctl -o cat -u async-nix-post-build-hook.service | grep 'We can access secret file secret_file: eQuei0xu'") 55 | ci.succeed("test -d /var/lib/async-nix-post-build-hook/.tmp") 56 | ''; 57 | } 58 | 59 | --------------------------------------------------------------------------------