├── .gitignore ├── Dockerfile ├── MIT-LICENSE ├── README.md ├── Taskfile.yml ├── docs ├── .nojekyll ├── README.md ├── imgs │ └── tuki-logo.jpeg └── index.html ├── go.mod ├── go.sum ├── imgs └── tuki-logo.jpeg ├── integration_test.go ├── internal ├── config.go ├── git_source.go ├── source.go ├── tasks.go └── tasks_test.go ├── main.go └── test └── testdata ├── basic-repo ├── .tuki │ └── .keep ├── fails.sh └── hello-world.sh └── python-harness-repo ├── .tuki ├── .keep └── harness.sh ├── fails.py └── hello-world.py /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # temp and build files 15 | tmp/ 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | # IDEs 31 | .idea/ 32 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.23-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache git 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy go mod files 11 | COPY go.mod go.sum ./ 12 | 13 | # Download dependencies 14 | RUN go mod download 15 | 16 | # Copy source code 17 | COPY . . 18 | 19 | # Build the application 20 | RUN CGO_ENABLED=0 GOOS=linux go build -o tuki 21 | 22 | # Final stage 23 | FROM alpine:latest 24 | 25 | # Install runtime dependencies 26 | RUN apk add --no-cache git bash openssh docker-cli 27 | 28 | # Copy binary from builder 29 | COPY --from=builder /app/tuki /usr/local/bin/ 30 | 31 | # Allow access to git repositories mounted as volumes 32 | RUN git config --global --add safe.directory '*' 33 | 34 | # Run the application 35 | ENTRYPOINT ["tuki"] 36 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Jason Nochlin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Safely Run Commands in Productions with Tuki! 3 |

4 |

5 | 6 |

7 | 8 | > **⚠️ Welcome:** This is alpha software. We are actively looking for collaborators to work on this project with us. If you are interested, please reach out or submit a pull request! 9 | 10 | 11 | # Tuki Task Runner 12 | 13 | Tuki provides an alternative to running commands in your production REPL or console directly. Instead, you can write your commands in a Git repository, use the standard git workflow to review and iterate on them, and then merge them into your production branch for execution. 14 | 15 | ## Features 16 | 17 | - Execute scripts in your production environment from a Git repository. 18 | - State is stored in the Git repository for persistence and visibility. 19 | - Harness file for customizing how tasks are run. 20 | 21 | ## Usage 22 | 23 | See docs [here](https://github.com/hundredwatt/tuki/tree/master/docs). 24 | 25 | ## Contributing 26 | 27 | Contributions are welcome! Please fork the repository and submit a pull request. 28 | 29 | ## Running Tests 30 | 31 | To run unit tests, use the following command: 32 | 33 | ```bash 34 | go test ./... 35 | ``` 36 | 37 | To run integration tests, use the following command: 38 | 39 | ```bash 40 | go test -tags=integration ./... 41 | ``` 42 | 43 | ## License 44 | 45 | This project is licensed under the MIT License - see the [MIT-LICENSE](./MIT-LICENSE) file for details. 46 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | vars: 4 | DOCKER_IMAGE: registry-76.localcan.dev/tuki 5 | VERSION: 'latest' 6 | 7 | tasks: 8 | test: 9 | desc: Run tests 10 | cmds: 11 | - go test ./... 12 | - go test -tags integration 13 | 14 | build: 15 | desc: Build the Docker image 16 | cmds: 17 | - docker build -t {{.DOCKER_IMAGE}}:{{.VERSION}} . 18 | 19 | push: 20 | desc: Push the Docker image to registry 21 | deps: [build] 22 | cmds: 23 | - docker push {{.DOCKER_IMAGE}}:{{.VERSION}} 24 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hundredwatt/tuki/42e3e866e124d46fe8e85f68c31f95df1c672605/docs/.nojekyll -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Tuki Task Runner 2 | 3 |

4 | 5 |

6 | 7 |

8 | Safely Run Commands in Productions with Tuki! 9 |

10 | 11 |

12 | 13 | GitHub 14 | 15 |

16 | 17 | Tuki provides an alternative to running commands in your production REPL or console directly. Instead, you can write your commands in a Git repository, use the standard git workflow to review and iterate on them, and then merge them into your production branch for execution. 18 | 19 | ## Features 20 | 21 | - Execute scripts in your production environment from a Git repository. 22 | - State is stored in the Git repository for persistence and visibility. 23 | - Harness file for customizing how tasks are run. 24 | 25 | ## Project Status 26 | 27 | Tuki is alpha software. We are actively looking for collaborators to work on this project with us. If you are interested, please reach out or submit a pull request! 28 | 29 | # Quick Start 30 | 31 | To use Tuki: 32 | 33 | 1. Create a new Github repository for your Tuki scripts. 34 | 2. Deploy Tuki to your production environment and configure it with the repo URL and other options. 35 | 3. Any scripts that are merged into the main branch will be run in the production environment. 36 | 37 | # Deployment 38 | 39 | Kamal can be deployed as a Go binary or as a Docker image. 40 | 41 | ## Kamal 42 | 43 | To deploy Tuki with Kamal, first complete the prequisites: 44 | 45 | 1. Create a new Github repository for your Tuki scripts. 46 | 2. Configure a harness file in your repository at `.tuki/harness.sh`, here's a Rails/Kamal example: 47 | 48 | ```sh 49 | #!/bin/sh 50 | 51 | docker run -i --rm --network kamal --env-file $KAMAL_ROLE_ENV_FILE my-rails-app:latest bin/rails runner - 52 | ``` 53 | 54 | 3. Generate a new SSH key on your server and add it as a Deploy Key to your Github repository. 55 | 4. Enable SSH agent as a daemon on your server: 56 | 57 | ``` 58 | # /etc/systemd/system/ssh-agent.service 59 | [Unit] 60 | Description=SSH Agent 61 | 62 | [Service] 63 | Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket 64 | ExecStart=/usr/bin/ssh-agent -D -a $SSH_AUTH_SOCK 65 | 66 | [Install] 67 | WantedBy=default.target 68 | ``` 69 | 70 | ```sh 71 | sudo systemctl enable ssh-agent 72 | sudo systemctl start ssh-agent 73 | ``` 74 | 75 | 5. Verify your server has access to the Github repository and adds Github to its known hosts file by running `ssh -T git@github.com` (get help [here](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/using-ssh-agent-forwarding)). 76 | 77 | 6. Configure an accessory in your `config/deploy.yml` file and boot it: 78 | 79 | ```yaml 80 | accessories: 81 | tuki: 82 | image: hundredwatt/tuki:latest 83 | host: xxx.xxx.xxx.xxx # replace with your server IP 84 | env: 85 | clear: 86 | REPO_URL: git@github.com... # replace with your scripts repo URL 87 | SSH_AUTH_SOCK: /ssh-agent/ssh-agent.socket 88 | volumes: 89 | # Share ssh agent socket and known hosts file with the container 90 | - "/run/user/$UID:/ssh-agent:ro" 91 | - "/home/$USER/.ssh/known_hosts:/root/.ssh/known_hosts:ro" 92 | # If your harness needs Docker access to launch other containers 93 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 94 | # if your harness needs Kamal env variables 95 | - "/home/$USER/.kamal/:/root/.kamal:ro" 96 | ``` 97 | 98 | ```sh 99 | kamal accessory boot tuki 100 | ``` 101 | 102 | 7. Any scripts that are merged into the main branch will be run in the production environment! 103 | 104 | # Configuration 105 | 106 | ## Harness File 107 | 108 | The harness file is a shell script that is used to run tasks. It's stored in the repository at `.tuki/harness.sh`. 109 | 110 | Each time a script is run, Tuki will run the harness file with the script contents as stdin. 111 | 112 | ### Postgres Example 113 | 114 | For example, if you wanted to run SQL scripts on Postgres, you could create a harness file that looks like this: 115 | 116 | ```sh 117 | #!/bin/sh 118 | 119 | psql -d $DATABASE_URL 120 | ``` 121 | 122 | Then you could create a task in the repository that looks like this: 123 | 124 | ```sql 125 | -- my-task.sql 126 | UPDATE users SET name = 'Tuki' WHERE id = 1; 127 | ``` 128 | 129 | ### Rails with Docker Example 130 | 131 | Another example harness file could be one that runs a Rails console script via docker: 132 | 133 | ```sh 134 | #!/bin/sh 135 | 136 | docker run -i --rm --network kamal --env-file $KAMAL_ROLE_ENV_FILE my-rails-app:latest bin/rails runner - 137 | ``` 138 | 139 | Then you could create a task in the repository that looks like this: 140 | 141 | ```ruby 142 | # my-task.rb 143 | User.first.update(name: 'Tuki') 144 | ``` 145 | 146 | ## Environment Variables 147 | 148 | Tuki is configured using environment variables. Below are the key configuration options: 149 | 150 | - `REPO_URL`: URL of the Git repository containing the scripts. 151 | - `TICK_INTERVAL_SECONDS`: Interval between checking for new scripts to run (defaults to 60 seconds). 152 | - `SCRIPTS_DIR`: Directory within the repository where scripts are located (defaults to `/`). 153 | - `VERBOSE`: Enable verbose logging when set to `true` (defaults to `false`). 154 | 155 | 156 | ## State File 157 | 158 | Tuki uses a state file to track which scripts have been run. It's stored in the repository at `.tuki/state.json`. Make sure your deploy keys have write access to the origin repository so updates to the state file can be pushed back to the repository for persistence and visibility. -------------------------------------------------------------------------------- /docs/imgs/tuki-logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hundredwatt/tuki/42e3e866e124d46fe8e85f68c31f95df1c672605/docs/imgs/tuki-logo.jpeg -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tuki 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/go-git/go-billy/v5 v5.5.0 7 | github.com/go-git/go-git/v5 v5.12.0 8 | github.com/google/uuid v1.6.0 9 | github.com/sirupsen/logrus v1.9.0 10 | github.com/stretchr/testify v1.9.0 11 | ) 12 | 13 | require ( 14 | dario.cat/mergo v1.0.0 // indirect 15 | github.com/Microsoft/go-winio v0.6.1 // indirect 16 | github.com/ProtonMail/go-crypto v1.0.0 // indirect 17 | github.com/cloudflare/circl v1.3.7 // indirect 18 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 19 | github.com/davecgh/go-spew v1.1.1 // indirect 20 | github.com/emirpasic/gods v1.18.1 // indirect 21 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 22 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 23 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 24 | github.com/kevinburke/ssh_config v1.2.0 // indirect 25 | github.com/pjbgf/sha1cd v0.3.0 // indirect 26 | github.com/pmezard/go-difflib v1.0.0 // indirect 27 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 28 | github.com/skeema/knownhosts v1.2.2 // indirect 29 | github.com/xanzy/ssh-agent v0.3.3 // indirect 30 | golang.org/x/crypto v0.28.0 // indirect 31 | golang.org/x/mod v0.21.0 // indirect 32 | golang.org/x/net v0.30.0 // indirect 33 | golang.org/x/sync v0.8.0 // indirect 34 | golang.org/x/sys v0.26.0 // indirect 35 | golang.org/x/tools v0.26.0 // indirect 36 | gopkg.in/warnings.v0 v0.1.2 // indirect 37 | gopkg.in/yaml.v3 v3.0.1 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 5 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 6 | github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= 7 | github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 13 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 14 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 15 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 16 | github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 17 | github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= 22 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 23 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 24 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 25 | github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= 26 | github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= 27 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 28 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 29 | github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= 30 | github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= 31 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 32 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 33 | github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= 34 | github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= 35 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 36 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 37 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 38 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 39 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 40 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 42 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 43 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 44 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 45 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 46 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 47 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 48 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 53 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 54 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 55 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 56 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 57 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 61 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 62 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 63 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 64 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 65 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 66 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 67 | github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= 68 | github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 71 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 72 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 73 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 74 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 75 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 76 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 77 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 78 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 79 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 80 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 81 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 82 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 83 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 84 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 85 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 86 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 87 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 88 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 89 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 90 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 91 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 92 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 93 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 94 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 95 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 96 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 97 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 98 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 99 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 100 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 101 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 102 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 103 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 104 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 117 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 118 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 119 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 120 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 121 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 122 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 123 | golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= 124 | golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= 125 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 126 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 127 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 128 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 129 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 130 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 131 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 132 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 133 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 134 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 135 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 136 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 137 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 138 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 139 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 140 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 141 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 142 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 143 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 144 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 145 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 146 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 147 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 148 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 149 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 150 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 151 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | -------------------------------------------------------------------------------- /imgs/tuki-logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hundredwatt/tuki/42e3e866e124d46fe8e85f68c31f95df1c672605/imgs/tuki-logo.jpeg -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "testing" 11 | 12 | "tuki/internal" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func must[T any](value T, err error) T { 18 | if err != nil { 19 | panic(err) 20 | } 21 | return value 22 | } 23 | 24 | func must0(err error) { 25 | if err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | func TestMain(t *testing.T) { 31 | // Setup 32 | bareRepoDir, repoDir := setupTestRepo(t, "test/testdata/basic-repo/") 33 | defer os.RemoveAll(bareRepoDir) 34 | defer os.RemoveAll(repoDir) 35 | 36 | // Run one tick of tuki 37 | runTuki(t, bareRepoDir) 38 | 39 | // Ensure that the state file was committed 40 | cmd := exec.Command("git", "log", "--patch", "-n", "1", "master") 41 | cmd.Dir = bareRepoDir 42 | output := must(cmd.Output()) 43 | assert.Contains(t, string(output), "state.json") 44 | 45 | // Check that the state file has 2 tasks, one failed (fails.sh) and one completed (hello-world.sh) 46 | cmd = exec.Command("git", "show", "master:.tuki/state.jsonl") 47 | cmd.Dir = bareRepoDir 48 | output = must(cmd.Output()) 49 | 50 | tasksStore := must(internal.LoadTaskStore(strings.NewReader(string(output)))) 51 | task, ok := tasksStore.GetTask("fails.sh") 52 | assert.True(t, ok) 53 | assert.True(t, task.Status == internal.StatusFailed) 54 | 55 | task, ok = tasksStore.GetTask("hello-world.sh") 56 | assert.True(t, ok) 57 | assert.True(t, task.Status == internal.StatusCompleted) 58 | } 59 | 60 | func TestHarnessFile(t *testing.T) { 61 | // Setup 62 | bareRepoDir, repoDir := setupTestRepo(t, "test/testdata/python-harness-repo/") 63 | defer os.RemoveAll(bareRepoDir) 64 | defer os.RemoveAll(repoDir) 65 | 66 | // Run one tick of tuki 67 | runTuki(t, bareRepoDir) 68 | 69 | // Ensure that the state file was committed 70 | cmd := exec.Command("git", "log", "--patch", "-n", "1", "master") 71 | cmd.Dir = bareRepoDir 72 | output := must(cmd.Output()) 73 | assert.Contains(t, string(output), "state.json") 74 | 75 | // Check that the state file has 2 tasks, one failed (fails.sh) and one completed (hello-world.sh) 76 | cmd = exec.Command("git", "show", "master:.tuki/state.jsonl") 77 | cmd.Dir = bareRepoDir 78 | output = must(cmd.Output()) 79 | 80 | tasksStore := must(internal.LoadTaskStore(strings.NewReader(string(output)))) 81 | task, ok := tasksStore.GetTask("fails.py") 82 | assert.True(t, ok) 83 | assert.True(t, task.Status == internal.StatusFailed) 84 | 85 | task, ok = tasksStore.GetTask("hello-world.py") 86 | assert.True(t, ok) 87 | assert.True(t, task.Status == internal.StatusCompleted) 88 | } 89 | 90 | func setupTestRepo(t *testing.T, repoPath string) (string, string) { 91 | bareRepoDir := must(os.MkdirTemp("", "tuki-testrepo-bare")) 92 | repoDir := must(os.MkdirTemp("", "tuki-testrepo")) 93 | 94 | cmd := exec.Command("cp", "-r", repoPath, repoDir) 95 | cmd.Dir = "." 96 | cmd.Stderr = os.Stderr 97 | must0(cmd.Run()) 98 | 99 | cmd = exec.Command("git", "init") 100 | cmd.Dir = repoDir 101 | must0(cmd.Run()) 102 | 103 | cmd = exec.Command("git", "add", ".") 104 | cmd.Dir = repoDir 105 | must0(cmd.Run()) 106 | 107 | cmd = exec.Command("git", "commit", "-m", "Initial commit") 108 | cmd.Dir = repoDir 109 | must0(cmd.Run()) 110 | 111 | cmd = exec.Command("git", "clone", "--bare", repoDir, bareRepoDir) 112 | must0(cmd.Run()) 113 | 114 | cmd = exec.Command("git", "remote", "add", "origin", bareRepoDir) 115 | cmd.Dir = repoDir 116 | must0(cmd.Run()) 117 | 118 | return bareRepoDir, repoDir 119 | } 120 | 121 | func runTuki(t *testing.T, bareRepoDir string) { 122 | os.Setenv("REPO_URL", bareRepoDir) 123 | defer os.Unsetenv("REPO_URL") 124 | 125 | os.Setenv("MAX_TICKS", "1") 126 | defer os.Unsetenv("MAX_TICKS") 127 | 128 | main() 129 | } -------------------------------------------------------------------------------- /internal/config.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type Mode string 11 | 12 | const ( 13 | Periodic Mode = "Periodic" 14 | Manual Mode = "Manual" 15 | ) 16 | 17 | type Config struct { 18 | RepoURL string 19 | ScriptsDir string 20 | InProgressTimeoutMinutes int 21 | StateFile string 22 | HarnessFile string 23 | TickIntervalSeconds int 24 | MaxTicks int 25 | Verbose bool 26 | } 27 | 28 | func LoadConfig() *Config { 29 | return &Config{ 30 | RepoURL: getEnvOrFatal("REPO_URL"), 31 | ScriptsDir: getEnvOrDefault("SCRIPTS_DIR", "/"), 32 | InProgressTimeoutMinutes: getEnvAsIntOrDefault("IN_PROGRESS_TIMEOUT_MINUTES", 60), 33 | StateFile: getEnvOrDefault("STATE_FILE", ".tuki/state.jsonl"), 34 | HarnessFile: getEnvOrDefault("HARNESS_FILE", ".tuki/harness.sh"), 35 | TickIntervalSeconds: getEnvAsIntOrDefault("TICK_INTERVAL_SECONDS", 60), 36 | MaxTicks: getEnvAsIntOrDefault("MAX_TICKS", -1), 37 | Verbose: getEnvAsBoolOrDefault("VERBOSE", false), 38 | } 39 | } 40 | 41 | func getEnvOrFatal(key string) string { 42 | value := os.Getenv(key) 43 | if value == "" { 44 | log.Fatalf("Environment variable %s is not set", key) 45 | } 46 | return value 47 | } 48 | 49 | func getEnvAsIntOrDefault(key string, defaultValue int) int { 50 | value := os.Getenv(key) 51 | if value == "" { 52 | return defaultValue 53 | } 54 | intValue, err := strconv.Atoi(value) 55 | if err != nil { 56 | log.Fatalf("Environment variable %s is not a valid integer: %v", key, err) 57 | } 58 | return intValue 59 | } 60 | 61 | func getEnvOrDefault(key string, defaultValue string) string { 62 | value := os.Getenv(key) 63 | if value == "" { 64 | return defaultValue 65 | } 66 | return value 67 | } 68 | 69 | func getEnvAsBoolOrDefault(key string, defaultValue bool) bool { 70 | value := strings.ToLower(os.Getenv(key)) 71 | if value == "" { 72 | return defaultValue 73 | } 74 | return value == "true" || value == "t" || value == "1" 75 | } 76 | -------------------------------------------------------------------------------- /internal/git_source.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "time" 10 | 11 | "github.com/go-git/go-billy/v5" 12 | "github.com/go-git/go-billy/v5/osfs" 13 | git "github.com/go-git/go-git/v5" 14 | "github.com/go-git/go-git/v5/plumbing" 15 | "github.com/go-git/go-git/v5/plumbing/object" 16 | "github.com/go-git/go-git/v5/storage/memory" 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | type GitSource struct { 21 | url string 22 | repository *git.Repository 23 | worktree *git.Worktree 24 | filesystem billy.Filesystem 25 | } 26 | 27 | func NewGitSource(url string) *GitSource { 28 | return &GitSource{ 29 | url: url, 30 | } 31 | } 32 | 33 | func (s *GitSource) Initialize(ctx context.Context) error { 34 | storage := memory.NewStorage() 35 | tempDir, err := os.MkdirTemp("", "tuki-clone") 36 | if err != nil { 37 | return fmt.Errorf("failed to create temp dir: %w", err) 38 | } 39 | filesystem := osfs.New(tempDir) 40 | s.filesystem = filesystem 41 | 42 | log.Info("Cloning repository ", s.url) 43 | 44 | repo, err := git.CloneContext(ctx, storage, filesystem, &git.CloneOptions{ 45 | URL: s.url, 46 | }) 47 | if err != nil { 48 | return fmt.Errorf("failed to clone repository: %w", err) 49 | } 50 | 51 | worktree, err := repo.Worktree() 52 | if err != nil { 53 | return fmt.Errorf("failed to get worktree: %w", err) 54 | } 55 | 56 | s.repository = repo 57 | s.worktree = worktree 58 | return nil 59 | } 60 | 61 | func (s *GitSource) FetchUpdates(ctx context.Context) error { 62 | remote, err := s.repository.Remote("origin") 63 | if err != nil { 64 | return fmt.Errorf("failed to get remote: %w", err) 65 | } 66 | 67 | err = remote.FetchContext(ctx, &git.FetchOptions{ 68 | Force: true, 69 | }) 70 | if err != nil && err != git.NoErrAlreadyUpToDate { 71 | return fmt.Errorf("failed to fetch repository: %w", err) 72 | } 73 | if err == git.NoErrAlreadyUpToDate { 74 | log.Info("Repository is already up to date") 75 | return nil 76 | } 77 | 78 | refs, err := remote.List(&git.ListOptions{}) 79 | if err != nil { 80 | return fmt.Errorf("failed to list references: %w", err) 81 | } 82 | 83 | var firstNonZeroRef *plumbing.Reference 84 | for _, ref := range refs { 85 | if !ref.Hash().IsZero() { 86 | firstNonZeroRef = ref 87 | break 88 | } 89 | } 90 | if firstNonZeroRef == nil { 91 | return fmt.Errorf("no non-zero references found") 92 | } 93 | 94 | err = s.worktree.Reset(&git.ResetOptions{ 95 | Mode: git.HardReset, 96 | Commit: firstNonZeroRef.Hash(), 97 | }) 98 | if err != nil { 99 | return fmt.Errorf("failed to reset worktree: %w", err) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func (s *GitSource) PublishChanges(ctx context.Context) error { 106 | status, err := s.worktree.Status() 107 | if err != nil { 108 | return fmt.Errorf("error getting worktree status: %v", err) 109 | } 110 | 111 | if status.IsClean() { 112 | return ErrNoChanges 113 | } 114 | 115 | _, err = s.worktree.Commit("Update state file", &git.CommitOptions{ 116 | Author: &object.Signature{ 117 | Name: "Tuki Bot", 118 | Email: "bot@tuki", 119 | When: time.Now(), 120 | }, 121 | }) 122 | if err != nil { 123 | return fmt.Errorf("error committing changes: %v", err) 124 | } 125 | 126 | if err := s.repository.PushContext(ctx, &git.PushOptions{ 127 | RemoteName: "origin", 128 | }); err != nil { 129 | return fmt.Errorf("error pushing to remote: %v", err) 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func (s *GitSource) HasChanges() (bool, error) { 136 | status, err := s.worktree.Status() 137 | if err != nil { 138 | return false, fmt.Errorf("error getting worktree status: %v", err) 139 | } 140 | return !status.IsClean(), nil 141 | } 142 | 143 | func (s *GitSource) ReadFile(path string) ([]byte, error) { 144 | file, err := s.filesystem.Open(path) 145 | if err != nil { 146 | return nil, err 147 | } 148 | defer file.Close() 149 | return io.ReadAll(file) 150 | } 151 | 152 | func (s *GitSource) WriteFile(path string, data []byte) error { 153 | file, err := s.filesystem.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) 154 | if err != nil { 155 | return err 156 | } 157 | defer file.Close() 158 | 159 | if _, err := file.Write(data); err != nil { 160 | return err 161 | } 162 | 163 | if _, err := s.worktree.Add(path); err != nil { 164 | return fmt.Errorf("error adding file to repository: %v", err) 165 | } 166 | 167 | return nil 168 | } 169 | 170 | func (s *GitSource) ReadDir(path string) ([]fs.FileInfo, error) { 171 | return s.filesystem.ReadDir(path) 172 | } 173 | 174 | func (s *GitSource) Root() string { 175 | return s.filesystem.Root() 176 | } 177 | 178 | func (s *GitSource) Close() error { 179 | return os.RemoveAll(s.filesystem.Root()) 180 | } 181 | -------------------------------------------------------------------------------- /internal/source.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/fs" 7 | ) 8 | 9 | // Common errors 10 | var ( 11 | ErrNotFound = errors.New("not found") 12 | ErrConflict = errors.New("conflict") 13 | ErrNoChanges = errors.New("no changes") 14 | ) 15 | 16 | // Source represents a syncable file source 17 | type Source interface { 18 | // Initialize sets up the source 19 | Initialize(ctx context.Context) error 20 | 21 | // FetchUpdates downloads changes from the remote source 22 | FetchUpdates(ctx context.Context) error 23 | 24 | // PublishChanges uploads local changes to the remote source 25 | PublishChanges(ctx context.Context) error 26 | 27 | // HasChanges checks if there are local modifications 28 | HasChanges() (bool, error) 29 | 30 | // File operations 31 | ReadFile(path string) ([]byte, error) 32 | WriteFile(path string, data []byte) error 33 | ReadDir(path string) ([]fs.FileInfo, error) 34 | 35 | // Root returns the root path of the workspace 36 | Root() string 37 | 38 | // Close cleans up any resources 39 | Close() error 40 | } -------------------------------------------------------------------------------- /internal/tasks.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "time" 9 | ) 10 | 11 | type Task struct { 12 | Name string `json:"name"` 13 | Status TaskStatus `json:"status"` 14 | LockedBy string `json:"locked_by,omitempty"` 15 | InProgressAt time.Time `json:"in_progress_at,omitempty"` 16 | ErrorMessage string `json:"error_message,omitempty"` 17 | } 18 | 19 | type TaskStore struct { 20 | Tasks []Task 21 | IndexOnName map[string]int 22 | } 23 | 24 | type TaskStatus string 25 | 26 | const ( 27 | StatusPending TaskStatus = "pending" 28 | StatusInProgress TaskStatus = "in_progress" 29 | StatusCompleted TaskStatus = "completed" 30 | StatusFailed TaskStatus = "failed" 31 | ) 32 | 33 | // MarshalJSON implements the json.Marshaler interface. 34 | func (s TaskStatus) MarshalJSON() ([]byte, error) { 35 | return json.Marshal(string(s)) 36 | } 37 | 38 | // UnmarshalJSON implements the json.Unmarshaler interface. 39 | func (s *TaskStatus) UnmarshalJSON(data []byte) error { 40 | var str string 41 | if err := json.Unmarshal(data, &str); err != nil { 42 | return err 43 | } 44 | *s = TaskStatus(str) 45 | return nil 46 | } 47 | 48 | func NewTaskStore() *TaskStore { 49 | return &TaskStore{ 50 | Tasks: make([]Task, 0), 51 | IndexOnName: map[string]int{}, 52 | } 53 | } 54 | 55 | func LoadTaskStore(reader io.Reader) (*TaskStore, error) { 56 | taskStore := NewTaskStore() 57 | index := 0 58 | 59 | scanner := bufio.NewScanner(reader) 60 | for scanner.Scan() { 61 | var task Task 62 | if err := json.Unmarshal(scanner.Bytes(), &task); err != nil { 63 | return nil, fmt.Errorf("error unmarshaling task: %v", err) 64 | } 65 | taskStore.Tasks = append(taskStore.Tasks, task) 66 | taskStore.IndexOnName[task.Name] = index 67 | index++ 68 | } 69 | 70 | if err := scanner.Err(); err != nil { 71 | return nil, fmt.Errorf("error reading state file: %v", err) 72 | } 73 | 74 | return taskStore, nil 75 | } 76 | 77 | func (ts *TaskStore) GetOrCreateTask(name string) Task { 78 | if task, ok := ts.GetTask(name); ok { 79 | return task 80 | } 81 | return Task{Name: name} 82 | } 83 | 84 | func (ts *TaskStore) GetTask(name string) (Task, bool) { 85 | if index, ok := ts.IndexOnName[name]; ok { 86 | return ts.Tasks[index], true 87 | } 88 | return Task{}, false 89 | } 90 | 91 | func (ts *TaskStore) UpsertTask(task Task) { 92 | if index, ok := ts.IndexOnName[task.Name]; ok { 93 | ts.Tasks[index] = task 94 | } else { 95 | ts.Tasks = append(ts.Tasks, task) 96 | ts.IndexOnName[task.Name] = len(ts.Tasks) - 1 97 | } 98 | } 99 | 100 | func (ts *TaskStore) SaveToFile(writer io.Writer) error { 101 | for _, task := range ts.Tasks { 102 | json.NewEncoder(writer).Encode(task) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/tasks_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAddAndUpdateTaskToTaskStore(t *testing.T) { 11 | taskStore := NewTaskStore() 12 | taskStore.UpsertTask(Task{Name: "test", Status: StatusPending}) 13 | taskStore.UpsertTask(Task{Name: "test2", Status: StatusPending}) 14 | taskStore.UpsertTask(Task{Name: "test", Status: StatusCompleted}) 15 | 16 | assert.Len(t, taskStore.Tasks, 2) 17 | assert.Len(t, taskStore.IndexOnName, 2) 18 | 19 | assert.Equal(t, taskStore.Tasks[0].Name, "test") 20 | assert.Equal(t, taskStore.Tasks[0].Status, StatusCompleted) 21 | assert.Equal(t, taskStore.Tasks[1].Name, "test2") 22 | assert.Equal(t, taskStore.Tasks[1].Status, StatusPending) 23 | } 24 | 25 | func TestSaveAndLoadStateFile(t *testing.T) { 26 | taskStore := NewTaskStore() 27 | taskStore.UpsertTask(Task{Name: "test", Status: StatusPending}) 28 | taskStore.UpsertTask(Task{Name: "test2", Status: StatusCompleted}) 29 | 30 | tmpFile, err := os.CreateTemp("", "teststatefile") 31 | if err != nil { 32 | t.Fatalf("Failed to create temp file: %v", err) 33 | } 34 | defer os.Remove(tmpFile.Name()) 35 | 36 | taskStore.SaveToFile(tmpFile) 37 | tmpFile.Close() 38 | 39 | // Re-open the file 40 | tmpFile, err = os.Open(tmpFile.Name()) 41 | if err != nil { 42 | t.Fatalf("Failed to open temp file: %v", err) 43 | } 44 | 45 | loadedTaskStore, err := LoadTaskStore(tmpFile) 46 | if err != nil { 47 | t.Fatalf("Failed to load task store: %v", err) 48 | } 49 | 50 | assert.Equal(t, taskStore.Tasks, loadedTaskStore.Tasks) 51 | } 52 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "path" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/google/uuid" 16 | 17 | "tuki/internal" 18 | 19 | log "github.com/sirupsen/logrus" 20 | ) 21 | 22 | const ( 23 | DefaultTaskRunnerCommand = "env sh" 24 | ) 25 | 26 | var ( 27 | config *internal.Config 28 | state *State 29 | ) 30 | 31 | type State struct { 32 | WorkerUUID string 33 | RunNumber int 34 | 35 | Source internal.Source 36 | TaskStore *internal.TaskStore 37 | } 38 | 39 | func main() { 40 | log.SetFormatter(&log.TextFormatter{ 41 | FullTimestamp: true, 42 | }) 43 | 44 | config = internal.LoadConfig() 45 | state = NewState() 46 | 47 | if config.Verbose { 48 | log.SetLevel(log.DebugLevel) 49 | } else { 50 | log.SetLevel(log.InfoLevel) 51 | } 52 | 53 | ctx, cancel := context.WithCancel(context.Background()) 54 | defer cancel() 55 | 56 | manualRunSignal := make(chan struct{}) 57 | 58 | go func() { 59 | sigCh := make(chan os.Signal, 1) 60 | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) 61 | for { 62 | sig := <-sigCh 63 | if sig == syscall.SIGUSR1 { 64 | log.Info("Received manual run signal (USR1).") 65 | manualRunSignal <- struct{}{} 66 | } else { 67 | log.Info("Received shutdown signal. Cancelling operations...") 68 | cancel() 69 | return 70 | } 71 | } 72 | }() 73 | 74 | if err := setup(ctx); err != nil { 75 | log.Fatal("Setup Error: ", err) 76 | } 77 | 78 | // Channels for time based triggering in Periodic mode 79 | var firstTick <-chan time.Time 80 | var ticker <-chan time.Time 81 | 82 | if config.TickIntervalSeconds >= 0 { 83 | firstTick = time.After(0) // Immediate first tick 84 | ticker = time.Tick(time.Duration(config.TickIntervalSeconds) * time.Second) 85 | } else { 86 | firstTick = make(chan time.Time) // A nil channel that will never receive 87 | ticker = make(chan time.Time) // A nil channel that will never receive 88 | } 89 | 90 | run := func() { 91 | if err := run(ctx); err != nil { 92 | log.Fatal("Run Error: ", err) 93 | } 94 | 95 | if config.MaxTicks >= 0 && state.RunNumber >= config.MaxTicks { 96 | cancel() 97 | } 98 | } 99 | 100 | for { 101 | select { 102 | case <-ctx.Done(): 103 | return 104 | case <-firstTick: 105 | run() 106 | case <-ticker: 107 | run() 108 | case <-manualRunSignal: 109 | run() 110 | } 111 | } 112 | } 113 | 114 | func NewState() *State { 115 | return &State{ 116 | WorkerUUID: uuid.NewString(), 117 | RunNumber: 1, 118 | } 119 | } 120 | 121 | func setup(ctx context.Context) error { 122 | source := internal.NewGitSource(config.RepoURL) 123 | state.Source = source 124 | 125 | return executeStep(ctx, "Initialize source", source.Initialize) 126 | } 127 | 128 | func run(ctx context.Context) error { 129 | log.Info("Running tick ", state.RunNumber) 130 | 131 | steps := []struct { 132 | name string 133 | fn func(context.Context) error 134 | }{ 135 | {"Fetch updates", fetchUpdates}, 136 | {"Read state file", readStateFile}, 137 | {"Update tasks state", updateTasksState}, 138 | {"Process tasks", processTasks}, 139 | {"Update state file", persistStateFile}, 140 | } 141 | 142 | for _, step := range steps { 143 | select { 144 | case <-ctx.Done(): 145 | return ctx.Err() 146 | default: 147 | if err := executeStep(ctx, step.name, step.fn); err != nil { 148 | return fmt.Errorf("failed to %s: %w", step.name, err) 149 | } 150 | } 151 | } 152 | 153 | state.RunNumber++ 154 | 155 | return nil 156 | } 157 | 158 | func executeStep(ctx context.Context, name string, fn func(context.Context) error) error { 159 | log.Debug("Executing step: ", name) 160 | err := fn(ctx) 161 | if err != nil { 162 | log.WithError(err).Error("Step '", name, "' failed") 163 | } else { 164 | log.Debug("Step '", name, "' completed successfully") 165 | } 166 | return err 167 | } 168 | 169 | func readStateFile(ctx context.Context) error { 170 | file, err := state.Source.ReadFile(config.StateFile) 171 | if err != nil && !os.IsNotExist(err) { 172 | return err 173 | } 174 | if os.IsNotExist(err) { 175 | state.TaskStore = internal.NewTaskStore() 176 | return nil 177 | } 178 | 179 | state.TaskStore, err = internal.LoadTaskStore(bytes.NewReader(file)) 180 | return err 181 | } 182 | 183 | func fetchUpdates(ctx context.Context) error { 184 | return state.Source.FetchUpdates(ctx) 185 | } 186 | 187 | func updateTasksState(ctx context.Context) error { 188 | // Read tasks from repository 189 | dir, err := state.Source.ReadDir(config.ScriptsDir) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | for _, file := range dir { 195 | if file.IsDir() { 196 | continue 197 | } 198 | 199 | task := state.TaskStore.GetOrCreateTask(file.Name()) 200 | 201 | if task.Name == "" { 202 | task.Name = file.Name() 203 | } 204 | 205 | if task.Status == "" { 206 | task.Status = internal.StatusPending 207 | } 208 | 209 | state.TaskStore.UpsertTask(task) 210 | } 211 | 212 | return nil 213 | } 214 | 215 | func handleTaskError(task internal.Task, state *State, err error, errMsg string) { 216 | log.WithError(err).Error("Task ", task.Name, " failed") 217 | task.Status = internal.StatusFailed 218 | task.ErrorMessage = fmt.Sprintf("%s: %v", errMsg, err) 219 | state.TaskStore.UpsertTask(task) 220 | } 221 | 222 | func processTasks(ctx context.Context) error { 223 | taskRunnerCommand, err := determineTaskRunnerCommand() 224 | if err != nil { 225 | return err 226 | } 227 | 228 | for _, task := range state.TaskStore.Tasks { 229 | select { 230 | case <-ctx.Done(): 231 | return ctx.Err() 232 | default: 233 | switch task.Status { 234 | case internal.StatusPending: 235 | // Run 236 | task := task 237 | task.Status = internal.StatusInProgress 238 | task.LockedBy = state.WorkerUUID 239 | task.InProgressAt = time.Now() 240 | state.TaskStore.UpsertTask(task) 241 | 242 | file, err := state.Source.ReadFile(path.Join(config.ScriptsDir, task.Name)) 243 | if err != nil { 244 | handleTaskError(task, state, err, "Failed to open file") 245 | continue 246 | } 247 | 248 | log.Info("Running task ", task.Name) 249 | cmd := exec.Command(taskRunnerCommand[0], taskRunnerCommand[1:]...) 250 | cmd.Dir = state.Source.Root() 251 | cmd.Stdin = strings.NewReader(string(file)) 252 | cmd.Stderr = &prefixWriter{prefix: task.Name, level: "ERROR"} 253 | cmd.Stdout = &prefixWriter{prefix: task.Name, level: "INFO"} 254 | if err := cmd.Run(); err != nil { 255 | handleTaskError(task, state, err, "Failed to run command") 256 | continue 257 | } 258 | 259 | task.Status = internal.StatusCompleted 260 | state.TaskStore.UpsertTask(task) 261 | case internal.StatusInProgress: 262 | if time.Since(task.InProgressAt) > time.Duration(config.InProgressTimeoutMinutes)*time.Minute { 263 | log.Warn("Task ", task.Name, " in progress for more than ", config.InProgressTimeoutMinutes, " minutes. Marking as failed.") 264 | 265 | task.Status = internal.StatusFailed 266 | task.ErrorMessage = "Task in progress for more than an hour" 267 | state.TaskStore.UpsertTask(task) 268 | } 269 | case internal.StatusCompleted: 270 | case internal.StatusFailed: 271 | continue 272 | } 273 | } 274 | } 275 | 276 | return nil 277 | } 278 | 279 | func persistStateFile(ctx context.Context) error { 280 | var buf bytes.Buffer 281 | if err := state.TaskStore.SaveToFile(&buf); err != nil { 282 | return fmt.Errorf("error serializing state: %v", err) 283 | } 284 | 285 | if err := state.Source.WriteFile(config.StateFile, buf.Bytes()); err != nil { 286 | return fmt.Errorf("error writing state file: %v", err) 287 | } 288 | 289 | if err := state.Source.PublishChanges(ctx); err != nil { 290 | if err == internal.ErrNoChanges { 291 | return nil 292 | } 293 | return fmt.Errorf("error publishing changes: %v", err) 294 | } 295 | 296 | return nil 297 | } 298 | 299 | func determineTaskRunnerCommand() ([]string, error) { 300 | harnessFilePath := path.Join(config.ScriptsDir, config.HarnessFile) 301 | if _, err := state.Source.ReadFile(harnessFilePath); os.IsNotExist(err) { 302 | return strings.Split(DefaultTaskRunnerCommand, " "), nil 303 | } 304 | 305 | return []string{"./" + config.HarnessFile}, nil 306 | } 307 | 308 | type prefixWriter struct { 309 | prefix string 310 | level string 311 | } 312 | 313 | func (w *prefixWriter) Write(p []byte) (n int, err error) { 314 | if w.level == "ERROR" { 315 | log.WithField("task", w.prefix).Error(string(p)) 316 | } else { 317 | log.WithField("task", w.prefix).Info(string(p)) 318 | } 319 | return len(p), nil 320 | } 321 | -------------------------------------------------------------------------------- /test/testdata/basic-repo/.tuki/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hundredwatt/tuki/42e3e866e124d46fe8e85f68c31f95df1c672605/test/testdata/basic-repo/.tuki/.keep -------------------------------------------------------------------------------- /test/testdata/basic-repo/fails.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | false -------------------------------------------------------------------------------- /test/testdata/basic-repo/hello-world.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | echo "Hello, World!" -------------------------------------------------------------------------------- /test/testdata/python-harness-repo/.tuki/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hundredwatt/tuki/42e3e866e124d46fe8e85f68c31f95df1c672605/test/testdata/python-harness-repo/.tuki/.keep -------------------------------------------------------------------------------- /test/testdata/python-harness-repo/.tuki/harness.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! command -v python3 > /dev/null 2>&1; then 4 | echo "python3 is not installed. Exiting." 5 | exit 1 6 | fi 7 | 8 | python3 -------------------------------------------------------------------------------- /test/testdata/python-harness-repo/fails.py: -------------------------------------------------------------------------------- 1 | exit(1) -------------------------------------------------------------------------------- /test/testdata/python-harness-repo/hello-world.py: -------------------------------------------------------------------------------- 1 | print("hello world") --------------------------------------------------------------------------------