├── .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 |
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")
--------------------------------------------------------------------------------