staryard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- staryard-0.1.0/LICENSE +21 -0
- staryard-0.1.0/PKG-INFO +136 -0
- staryard-0.1.0/README.md +110 -0
- staryard-0.1.0/app/__init__.py +10 -0
- staryard-0.1.0/app/__main__.py +3 -0
- staryard-0.1.0/app/api.py +337 -0
- staryard-0.1.0/app/cli.py +1126 -0
- staryard-0.1.0/app/config.py +60 -0
- staryard-0.1.0/app/errors.py +9 -0
- staryard-0.1.0/app/machine.py +430 -0
- staryard-0.1.0/app/main.py +51 -0
- staryard-0.1.0/app/podman.py +379 -0
- staryard-0.1.0/app/rows.py +286 -0
- staryard-0.1.0/app/store.py +706 -0
- staryard-0.1.0/app/text.py +300 -0
- staryard-0.1.0/app/yard.py +904 -0
- staryard-0.1.0/pyproject.toml +92 -0
staryard-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Bobowski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
staryard-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: staryard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command line for a Yard host.
|
|
5
|
+
Keywords: yard,cli,containers,deploy
|
|
6
|
+
Author: Adam Bobowski
|
|
7
|
+
Author-email: Adam Bobowski <adam.bobowski@wratilabs.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: System :: Systems Administration
|
|
17
|
+
Requires-Dist: stario>=4,<5 ; extra == 'server'
|
|
18
|
+
Requires-Dist: uvloop>=0.21.0 ; extra == 'server'
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Project-URL: Changelog, https://github.com/Bobowski/yard/blob/main/CHANGELOG.md
|
|
21
|
+
Project-URL: Homepage, https://github.com/Bobowski/yard
|
|
22
|
+
Project-URL: Issues, https://github.com/Bobowski/yard/issues
|
|
23
|
+
Project-URL: Repository, https://github.com/Bobowski/yard
|
|
24
|
+
Provides-Extra: server
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Yard
|
|
28
|
+
|
|
29
|
+
Yard runs each app in its own container. You push a repo. Yard builds the Containerfile and starts the container. Caddy sends public traffic to that container.
|
|
30
|
+
|
|
31
|
+
## Slot
|
|
32
|
+
|
|
33
|
+
A slot is one app. The container name is the slot name.
|
|
34
|
+
|
|
35
|
+
A slot points at one repo and one branch. The repo must already contain one of these files:
|
|
36
|
+
|
|
37
|
+
- `Containerfile`
|
|
38
|
+
- `Dockerfile`
|
|
39
|
+
- `ops/Containerfile`
|
|
40
|
+
- `ops/Dockerfile`
|
|
41
|
+
|
|
42
|
+
A push to that branch builds the image and starts the container. Yard removes the slot when that branch is merged or deleted.
|
|
43
|
+
|
|
44
|
+
`--every 5m` makes a job. Yard keeps one run at a time. The next run uses the same container name.
|
|
45
|
+
|
|
46
|
+
## Host
|
|
47
|
+
|
|
48
|
+
Yard keeps its files under `/srv/yard`. Each slot keeps its files under `/srv/yard/data/<name>`.
|
|
49
|
+
|
|
50
|
+
Caddy listens on port 8443. The host forwards port 443 to that port. Yard posts the Caddy config to a Unix socket.
|
|
51
|
+
|
|
52
|
+
## CLI
|
|
53
|
+
|
|
54
|
+
The command is `yard`. The package name is `staryard`. PyPI already has a package named `yard`.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
uv tool install staryard
|
|
58
|
+
yard --version
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Python 3.14 is required. `yard login` stores the host and the token on this computer.
|
|
62
|
+
|
|
63
|
+
`staryard[server]` installs the server libraries. The host still runs the container image.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
Run the script on the VM as root. A second run repairs a partial install. A healthy Yard container stays in place.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
./install.sh --domain yard.example.com
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The script installs `ghcr.io/bobowski/yard:latest`. A finished run has checked the health page, the container route, and the path to Let's Encrypt. With `--domain` it also checks the certificate and `https://<domain>/health`.
|
|
74
|
+
|
|
75
|
+
The script prints an admin token. Log in from your computer:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
yard login --url https://yard.example.com --token <token>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The package must be public. A private package needs `podman login ghcr.io` on the VM before the script.
|
|
82
|
+
|
|
83
|
+
GitHub Actions builds the image when this directory is the root of the repo. The workflow publishes `ghcr.io/bobowski/yard:latest` for `linux/amd64`.
|
|
84
|
+
|
|
85
|
+
## Update Yard
|
|
86
|
+
|
|
87
|
+
`yard update` pulls `ghcr.io/bobowski/yard:latest` and swaps the Yard container. `yard rollback` starts the previous image.
|
|
88
|
+
|
|
89
|
+
A repo on this host replaces that image:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
yard settings --self-repo yard
|
|
93
|
+
yard update
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Clear the repo to return to the image:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
yard settings --self-image
|
|
100
|
+
yard update
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Add an app
|
|
104
|
+
|
|
105
|
+
Run this in the app directory. The directory name is the repo name and the slot name.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
yard init --domain heldso.example.com
|
|
109
|
+
git push -u yard main
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`yard init` makes a git repo when the directory has none. It makes the Yard repo and the slot. It adds a git remote named `yard`. It does not push.
|
|
113
|
+
|
|
114
|
+
Pass `--domain` for a public host. Pass `--every 5m` for a job. One of them is required. `--name` sets the repo name and the slot name. `--repo` and `--slot` set one each.
|
|
115
|
+
|
|
116
|
+
`yard repo new`, `yard repo link`, and `yard slot new` do those steps one at a time.
|
|
117
|
+
|
|
118
|
+
## Commands
|
|
119
|
+
|
|
120
|
+
Run `yard` to see every command. These are the common ones:
|
|
121
|
+
|
|
122
|
+
- `yard slot list` shows each slot, its data size, and how long the last build took. The last row is the host total.
|
|
123
|
+
- `yard slot edit <name>` changes the slot file.
|
|
124
|
+
- `yard slot logs <name>` reads the container log.
|
|
125
|
+
- `yard settings` sets the public host, the Caddy email, and the Yard image.
|
|
126
|
+
|
|
127
|
+
A terminal prints text. Any other stdout prints JSON.
|
|
128
|
+
|
|
129
|
+
## Develop
|
|
130
|
+
|
|
131
|
+
The server command is `python -m app.main`.
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
uv run pytest
|
|
135
|
+
STARIO_LOOP=uvloop uv run python -m app.main
|
|
136
|
+
```
|
staryard-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Yard
|
|
2
|
+
|
|
3
|
+
Yard runs each app in its own container. You push a repo. Yard builds the Containerfile and starts the container. Caddy sends public traffic to that container.
|
|
4
|
+
|
|
5
|
+
## Slot
|
|
6
|
+
|
|
7
|
+
A slot is one app. The container name is the slot name.
|
|
8
|
+
|
|
9
|
+
A slot points at one repo and one branch. The repo must already contain one of these files:
|
|
10
|
+
|
|
11
|
+
- `Containerfile`
|
|
12
|
+
- `Dockerfile`
|
|
13
|
+
- `ops/Containerfile`
|
|
14
|
+
- `ops/Dockerfile`
|
|
15
|
+
|
|
16
|
+
A push to that branch builds the image and starts the container. Yard removes the slot when that branch is merged or deleted.
|
|
17
|
+
|
|
18
|
+
`--every 5m` makes a job. Yard keeps one run at a time. The next run uses the same container name.
|
|
19
|
+
|
|
20
|
+
## Host
|
|
21
|
+
|
|
22
|
+
Yard keeps its files under `/srv/yard`. Each slot keeps its files under `/srv/yard/data/<name>`.
|
|
23
|
+
|
|
24
|
+
Caddy listens on port 8443. The host forwards port 443 to that port. Yard posts the Caddy config to a Unix socket.
|
|
25
|
+
|
|
26
|
+
## CLI
|
|
27
|
+
|
|
28
|
+
The command is `yard`. The package name is `staryard`. PyPI already has a package named `yard`.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv tool install staryard
|
|
32
|
+
yard --version
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.14 is required. `yard login` stores the host and the token on this computer.
|
|
36
|
+
|
|
37
|
+
`staryard[server]` installs the server libraries. The host still runs the container image.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
Run the script on the VM as root. A second run repairs a partial install. A healthy Yard container stays in place.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
./install.sh --domain yard.example.com
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The script installs `ghcr.io/bobowski/yard:latest`. A finished run has checked the health page, the container route, and the path to Let's Encrypt. With `--domain` it also checks the certificate and `https://<domain>/health`.
|
|
48
|
+
|
|
49
|
+
The script prints an admin token. Log in from your computer:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
yard login --url https://yard.example.com --token <token>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The package must be public. A private package needs `podman login ghcr.io` on the VM before the script.
|
|
56
|
+
|
|
57
|
+
GitHub Actions builds the image when this directory is the root of the repo. The workflow publishes `ghcr.io/bobowski/yard:latest` for `linux/amd64`.
|
|
58
|
+
|
|
59
|
+
## Update Yard
|
|
60
|
+
|
|
61
|
+
`yard update` pulls `ghcr.io/bobowski/yard:latest` and swaps the Yard container. `yard rollback` starts the previous image.
|
|
62
|
+
|
|
63
|
+
A repo on this host replaces that image:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
yard settings --self-repo yard
|
|
67
|
+
yard update
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Clear the repo to return to the image:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
yard settings --self-image
|
|
74
|
+
yard update
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Add an app
|
|
78
|
+
|
|
79
|
+
Run this in the app directory. The directory name is the repo name and the slot name.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
yard init --domain heldso.example.com
|
|
83
|
+
git push -u yard main
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`yard init` makes a git repo when the directory has none. It makes the Yard repo and the slot. It adds a git remote named `yard`. It does not push.
|
|
87
|
+
|
|
88
|
+
Pass `--domain` for a public host. Pass `--every 5m` for a job. One of them is required. `--name` sets the repo name and the slot name. `--repo` and `--slot` set one each.
|
|
89
|
+
|
|
90
|
+
`yard repo new`, `yard repo link`, and `yard slot new` do those steps one at a time.
|
|
91
|
+
|
|
92
|
+
## Commands
|
|
93
|
+
|
|
94
|
+
Run `yard` to see every command. These are the common ones:
|
|
95
|
+
|
|
96
|
+
- `yard slot list` shows each slot, its data size, and how long the last build took. The last row is the host total.
|
|
97
|
+
- `yard slot edit <name>` changes the slot file.
|
|
98
|
+
- `yard slot logs <name>` reads the container log.
|
|
99
|
+
- `yard settings` sets the public host, the Caddy email, and the Yard image.
|
|
100
|
+
|
|
101
|
+
A terminal prints text. Any other stdout prints JSON.
|
|
102
|
+
|
|
103
|
+
## Develop
|
|
104
|
+
|
|
105
|
+
The server command is `python -m app.main`.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
uv run pytest
|
|
109
|
+
STARIO_LOOP=uvloop uv run python -m app.main
|
|
110
|
+
```
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""JSON API. Git smart HTTP sits beside it and uses basic auth."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import stario.responses as responses
|
|
8
|
+
from stario import App, Context, Handler, Middleware, Route, Writer
|
|
9
|
+
from stario.responses import JsonValue
|
|
10
|
+
|
|
11
|
+
from app.errors import YardError
|
|
12
|
+
from app.podman import inspect_args
|
|
13
|
+
from app.store import Experiment, Label
|
|
14
|
+
from app.text import apply_slot_keys, slot_values
|
|
15
|
+
from app.yard import Yard
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_api(app: App, yard: Yard) -> None:
|
|
19
|
+
async def health(_c: Context, w: Writer) -> None:
|
|
20
|
+
responses.empty(w, 200)
|
|
21
|
+
|
|
22
|
+
app.add(Route("GET /health"), health)
|
|
23
|
+
app.add(Route("GET /git/{path...}"), yard.serve_git)
|
|
24
|
+
app.add(Route("POST /git/{path...}"), yard.serve_git)
|
|
25
|
+
|
|
26
|
+
auth = (_bearer(yard),)
|
|
27
|
+
|
|
28
|
+
async def repos_list(_c: Context, w: Writer) -> None:
|
|
29
|
+
responses.json(w, [asdict(repo) for repo in yard.store.list_repos()])
|
|
30
|
+
|
|
31
|
+
async def repos_create(c: Context, w: Writer) -> None:
|
|
32
|
+
body = await _object(c)
|
|
33
|
+
name = body.get("name", "")
|
|
34
|
+
force = body.get("force", False)
|
|
35
|
+
if not isinstance(name, str) or not isinstance(force, bool):
|
|
36
|
+
raise YardError(400, "bad_json", "bad json")
|
|
37
|
+
responses.json(w, await yard.create_repo(name, force))
|
|
38
|
+
|
|
39
|
+
async def repos_delete(c: Context, w: Writer) -> None:
|
|
40
|
+
name = c.match.params["name"]
|
|
41
|
+
yard.delete_repo(name)
|
|
42
|
+
responses.json(w, {"name": name})
|
|
43
|
+
|
|
44
|
+
app.add(Route("GET /api/v1/repos"), repos_list, middleware=auth)
|
|
45
|
+
app.add(Route("POST /api/v1/repos"), repos_create, middleware=auth)
|
|
46
|
+
app.add(Route("DELETE /api/v1/repos/{name}"), repos_delete, middleware=auth)
|
|
47
|
+
|
|
48
|
+
async def slots_list(c: Context, w: Writer) -> None:
|
|
49
|
+
responses.json(w, await yard.list_rows(c.req.query.get("running") == "1"))
|
|
50
|
+
|
|
51
|
+
async def slots_create(c: Context, w: Writer) -> None:
|
|
52
|
+
slot, warn, note = await yard.create_slot(_string_map(await _object(c)))
|
|
53
|
+
responses.json(w, cast(JsonValue, {"values": slot_values(slot), "warnings": warn, "deploy": note}))
|
|
54
|
+
|
|
55
|
+
async def slots_get(c: Context, w: Writer) -> None:
|
|
56
|
+
responses.json(w, cast(JsonValue, slot_values(yard.store.slot(c.match.params["name"]))))
|
|
57
|
+
|
|
58
|
+
async def slots_put(c: Context, w: Writer) -> None:
|
|
59
|
+
name = c.match.params["name"]
|
|
60
|
+
edit = apply_slot_keys(yard.store.slot(name), _string_map(await _object(c)))
|
|
61
|
+
slot = await yard.update_slot(name, edit)
|
|
62
|
+
responses.json(w, cast(JsonValue, {"values": slot_values(slot), "warnings": edit.warn}))
|
|
63
|
+
|
|
64
|
+
async def slots_delete(c: Context, w: Writer) -> None:
|
|
65
|
+
name = c.match.params["name"]
|
|
66
|
+
await yard.delete_slot(name)
|
|
67
|
+
responses.json(w, {"name": name})
|
|
68
|
+
|
|
69
|
+
async def logs(c: Context, w: Writer) -> None:
|
|
70
|
+
name = c.match.params["name"]
|
|
71
|
+
tail_text = c.req.query.get("tail") or "200"
|
|
72
|
+
if not tail_text.isdigit() or int(tail_text) < 1:
|
|
73
|
+
raise YardError(400, "bad_tail", "tail must be a positive number")
|
|
74
|
+
tail = int(tail_text)
|
|
75
|
+
cursor = c.req.query.get("cursor") or ""
|
|
76
|
+
container = c.req.query.get("container") or ""
|
|
77
|
+
if c.req.query.get("follow") == "1":
|
|
78
|
+
await _follow(yard, w, name, tail, cursor, container)
|
|
79
|
+
return
|
|
80
|
+
responses.json(w, await yard.log_page(name, tail, cursor, container))
|
|
81
|
+
|
|
82
|
+
async def deploys(c: Context, w: Writer) -> None:
|
|
83
|
+
name = c.match.params["name"]
|
|
84
|
+
yard.store.slot(name)
|
|
85
|
+
limit = c.req.query.get("limit") or "0"
|
|
86
|
+
rows = yard.store.list_deploys(name, int(limit) if limit.isdigit() else 0)
|
|
87
|
+
responses.json(w, [asdict(row) for row in rows])
|
|
88
|
+
|
|
89
|
+
async def deploy(c: Context, w: Writer) -> None:
|
|
90
|
+
name = c.match.params["name"]
|
|
91
|
+
yard.store.slot(name)
|
|
92
|
+
raw = c.match.params["id"]
|
|
93
|
+
if not raw.isdigit():
|
|
94
|
+
raise YardError(400, "bad_id", "deploy id must be a number")
|
|
95
|
+
row = yard.store.deploy(int(raw))
|
|
96
|
+
if row.slot != name:
|
|
97
|
+
raise YardError(404, "not_found", "deploy does not exist")
|
|
98
|
+
responses.json(w, asdict(row))
|
|
99
|
+
|
|
100
|
+
async def inspect(c: Context, w: Writer) -> None:
|
|
101
|
+
text = await yard.podman.run(*inspect_args(c.match.params["name"]))
|
|
102
|
+
if text and not text.endswith("\n"):
|
|
103
|
+
text += "\n"
|
|
104
|
+
responses.text(w, text)
|
|
105
|
+
|
|
106
|
+
app.add(Route("GET /api/v1/slots"), slots_list, middleware=auth)
|
|
107
|
+
app.add(Route("POST /api/v1/slots"), slots_create, middleware=auth)
|
|
108
|
+
app.add(Route("GET /api/v1/slots/{name}"), slots_get, middleware=auth)
|
|
109
|
+
app.add(Route("PUT /api/v1/slots/{name}"), slots_put, middleware=auth)
|
|
110
|
+
app.add(Route("DELETE /api/v1/slots/{name}"), slots_delete, middleware=auth)
|
|
111
|
+
app.add(Route("GET /api/v1/slots/{name}/logs"), logs, middleware=auth)
|
|
112
|
+
app.add(Route("GET /api/v1/slots/{name}/deploys"), deploys, middleware=auth)
|
|
113
|
+
app.add(Route("GET /api/v1/slots/{name}/deploys/{id}"), deploy, middleware=auth)
|
|
114
|
+
app.add(Route("GET /api/v1/containers/{name}"), inspect, middleware=auth)
|
|
115
|
+
|
|
116
|
+
async def settings_get(_c: Context, w: Writer) -> None:
|
|
117
|
+
responses.json(w, asdict(yard.store.settings()))
|
|
118
|
+
|
|
119
|
+
async def settings_put(c: Context, w: Writer) -> None:
|
|
120
|
+
body = await _object(c)
|
|
121
|
+
responses.json(
|
|
122
|
+
w,
|
|
123
|
+
await yard.save_settings(
|
|
124
|
+
_optional(body, "domain"),
|
|
125
|
+
_optional(body, "email"),
|
|
126
|
+
_optional(body, "self_repo"),
|
|
127
|
+
_optional(body, "self_ref"),
|
|
128
|
+
_optional(body, "self_image"),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
app.add(Route("GET /api/v1/settings"), settings_get, middleware=auth)
|
|
133
|
+
app.add(Route("PUT /api/v1/settings"), settings_put, middleware=auth)
|
|
134
|
+
|
|
135
|
+
async def tokens_list(_c: Context, w: Writer) -> None:
|
|
136
|
+
responses.json(w, [asdict(token) for token in yard.store.list_tokens()])
|
|
137
|
+
|
|
138
|
+
async def tokens_create(c: Context, w: Writer) -> None:
|
|
139
|
+
body = await _object(c)
|
|
140
|
+
name = body.get("name", "")
|
|
141
|
+
if not isinstance(name, str):
|
|
142
|
+
raise YardError(400, "bad_json", "bad json")
|
|
143
|
+
actor = str(c.state.get("actor") or "")
|
|
144
|
+
plain = yard.store.create_token(name, actor)
|
|
145
|
+
responses.json(w, {"name": name, "token": plain, "parent": actor})
|
|
146
|
+
|
|
147
|
+
async def tokens_delete(c: Context, w: Writer) -> None:
|
|
148
|
+
name = c.match.params["name"]
|
|
149
|
+
yard.store.revoke_token(str(c.state.get("actor") or ""), name)
|
|
150
|
+
responses.json(w, {"name": name})
|
|
151
|
+
|
|
152
|
+
app.add(Route("GET /api/v1/tokens"), tokens_list, middleware=auth)
|
|
153
|
+
app.add(Route("POST /api/v1/tokens"), tokens_create, middleware=auth)
|
|
154
|
+
app.add(Route("DELETE /api/v1/tokens/{name}"), tokens_delete, middleware=auth)
|
|
155
|
+
|
|
156
|
+
async def experiments_list(c: Context, w: Writer) -> None:
|
|
157
|
+
rows = _filter_exps(yard.store.list_experiments(), c.req.query.items())
|
|
158
|
+
responses.json(w, [asdict(row) for row in rows])
|
|
159
|
+
|
|
160
|
+
async def experiments_create(c: Context, w: Writer) -> None:
|
|
161
|
+
title, body, labels = await _exp_body(c)
|
|
162
|
+
saved = yard.store.save_experiments([(False, 0, title, body, labels)], [])
|
|
163
|
+
responses.json(w, asdict(saved[0]))
|
|
164
|
+
|
|
165
|
+
async def experiments_get(c: Context, w: Writer) -> None:
|
|
166
|
+
responses.json(w, asdict(_one_exp(yard, c.match.params["id"])))
|
|
167
|
+
|
|
168
|
+
async def experiments_put(c: Context, w: Writer) -> None:
|
|
169
|
+
exp = _one_exp(yard, c.match.params["id"])
|
|
170
|
+
title, body, labels = await _exp_body(c)
|
|
171
|
+
saved = yard.store.save_experiments([(True, exp.id, title, body, labels)], [])
|
|
172
|
+
responses.json(w, asdict(saved[0]))
|
|
173
|
+
|
|
174
|
+
async def experiments_delete(c: Context, w: Writer) -> None:
|
|
175
|
+
exp = _one_exp(yard, c.match.params["id"])
|
|
176
|
+
yard.store.save_experiments([], [exp.id])
|
|
177
|
+
responses.json(w, {"id": exp.id})
|
|
178
|
+
|
|
179
|
+
app.add(Route("GET /api/v1/experiments"), experiments_list, middleware=auth)
|
|
180
|
+
app.add(Route("POST /api/v1/experiments"), experiments_create, middleware=auth)
|
|
181
|
+
app.add(Route("GET /api/v1/experiments/{id}"), experiments_get, middleware=auth)
|
|
182
|
+
app.add(Route("PUT /api/v1/experiments/{id}"), experiments_put, middleware=auth)
|
|
183
|
+
app.add(Route("DELETE /api/v1/experiments/{id}"), experiments_delete, middleware=auth)
|
|
184
|
+
|
|
185
|
+
async def hook(c: Context, w: Writer) -> None:
|
|
186
|
+
body = await _object(c)
|
|
187
|
+
repo, old, new, ref = body.get("repo", ""), body.get("old", ""), body.get("new", ""), body.get("ref", "")
|
|
188
|
+
if not all(isinstance(item, str) for item in (repo, old, new, ref)):
|
|
189
|
+
raise YardError(400, "bad_json", "bad json")
|
|
190
|
+
status, text = await yard.hook_push(repo, old, new, ref)
|
|
191
|
+
responses.text(w, text, status)
|
|
192
|
+
|
|
193
|
+
async def yard_post(c: Context, w: Writer) -> None:
|
|
194
|
+
action = (await _object(c)).get("action", "")
|
|
195
|
+
if not isinstance(action, str):
|
|
196
|
+
raise YardError(400, "bad_json", "bad json")
|
|
197
|
+
if action == "rollback":
|
|
198
|
+
await yard.rollback()
|
|
199
|
+
responses.json(w, {"image": "localhost/yard:previous", "container": "yard-swap"})
|
|
200
|
+
return
|
|
201
|
+
if action not in {"", "update"}:
|
|
202
|
+
raise YardError(400, "bad_action", "action is update or rollback")
|
|
203
|
+
responses.json(w, await yard.update_yard())
|
|
204
|
+
|
|
205
|
+
async def yard_get(_c: Context, w: Writer) -> None:
|
|
206
|
+
responses.json(w, yard.swap_status())
|
|
207
|
+
|
|
208
|
+
app.add(Route("POST /api/v1/hooks/push"), hook, middleware=auth)
|
|
209
|
+
app.add(Route("POST /api/v1/yard"), yard_post, middleware=auth)
|
|
210
|
+
app.add(Route("GET /api/v1/yard"), yard_get, middleware=auth)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _bearer(yard: Yard) -> Middleware:
|
|
214
|
+
def middleware(handler: Handler) -> Handler:
|
|
215
|
+
async def wrapped(c: Context, w: Writer) -> None:
|
|
216
|
+
header = c.req.headers.get("authorization") or ""
|
|
217
|
+
raw = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
|
218
|
+
try:
|
|
219
|
+
token = yard.store.token_ok(raw)
|
|
220
|
+
except YardError as exc:
|
|
221
|
+
responses.json(w, {"error": {"code": exc.code, "message": exc.message}}, exc.status)
|
|
222
|
+
return
|
|
223
|
+
if token is None:
|
|
224
|
+
responses.json(w, {"error": {"code": "unauthorized", "message": "unauthorized"}}, 401)
|
|
225
|
+
return
|
|
226
|
+
c.state["actor"] = token.name
|
|
227
|
+
try:
|
|
228
|
+
await handler(c, w)
|
|
229
|
+
except YardError as exc:
|
|
230
|
+
# The writer already sent bytes. A second body would corrupt that response.
|
|
231
|
+
if not w.started:
|
|
232
|
+
responses.json(w, {"error": {"code": exc.code, "message": exc.message}}, exc.status)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
if not w.started:
|
|
235
|
+
responses.json(w, {"error": {"code": "internal", "message": str(exc)}}, 500)
|
|
236
|
+
else:
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
return wrapped
|
|
240
|
+
|
|
241
|
+
return middleware
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _follow(yard: Yard, w: Writer, name: str, tail: int, cursor: str, container: str) -> None:
|
|
245
|
+
yard.store.slot(name)
|
|
246
|
+
target = container or await yard.log_target(name)
|
|
247
|
+
args = ["logs", "--follow", "--tail", str(tail)]
|
|
248
|
+
if cursor:
|
|
249
|
+
args.extend(("--since", cursor))
|
|
250
|
+
args.append(target)
|
|
251
|
+
proc = await yard.podman.popen(*args)
|
|
252
|
+
w.headers.set("content-type", "text/plain; charset=utf-8")
|
|
253
|
+
w.write_headers(200)
|
|
254
|
+
assert proc.stdout is not None
|
|
255
|
+
try:
|
|
256
|
+
while chunk := await proc.stdout.read(8192):
|
|
257
|
+
w.write(chunk)
|
|
258
|
+
finally:
|
|
259
|
+
if proc.returncode is None:
|
|
260
|
+
proc.kill()
|
|
261
|
+
await proc.wait()
|
|
262
|
+
w.end()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _object(c: Context) -> dict:
|
|
266
|
+
raw = await c.req.body()
|
|
267
|
+
try:
|
|
268
|
+
body = json.loads(raw) if raw else None
|
|
269
|
+
except json.JSONDecodeError as exc:
|
|
270
|
+
raise YardError(400, "bad_json", "bad json") from exc
|
|
271
|
+
if not isinstance(body, dict):
|
|
272
|
+
raise YardError(400, "bad_json", "bad json")
|
|
273
|
+
return body
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _string_map(body: dict) -> dict[str, str]:
|
|
277
|
+
found: dict[str, str] = {}
|
|
278
|
+
for key, value in body.items():
|
|
279
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
280
|
+
raise YardError(400, "bad_json", "bad json")
|
|
281
|
+
found[key] = value
|
|
282
|
+
return found
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _optional(body: dict, key: str) -> str | None:
|
|
286
|
+
if key not in body:
|
|
287
|
+
return None
|
|
288
|
+
value = body[key]
|
|
289
|
+
if not isinstance(value, str):
|
|
290
|
+
raise YardError(400, "bad_json", "bad json")
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _one_exp(yard: Yard, raw: str) -> Experiment:
|
|
295
|
+
if not raw.isdigit() or int(raw) <= 0:
|
|
296
|
+
raise YardError(400, "bad_id", "experiment id must be a number")
|
|
297
|
+
exp_id = int(raw)
|
|
298
|
+
for exp in yard.store.list_experiments():
|
|
299
|
+
if exp.id == exp_id:
|
|
300
|
+
return exp
|
|
301
|
+
raise YardError(404, "not_found", "experiment does not exist")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _exp_body(c: Context) -> tuple[str, str, list[Label]]:
|
|
305
|
+
body = await _object(c)
|
|
306
|
+
title = body.get("title", "")
|
|
307
|
+
text = body.get("body", "")
|
|
308
|
+
labels = body.get("labels", [])
|
|
309
|
+
if not isinstance(title, str) or not isinstance(text, str) or not isinstance(labels, list):
|
|
310
|
+
raise YardError(400, "bad_json", "bad json")
|
|
311
|
+
if not title.strip():
|
|
312
|
+
raise YardError(400, "bad_title", "a title is required")
|
|
313
|
+
parsed: list[Label] = []
|
|
314
|
+
for item in labels:
|
|
315
|
+
if not isinstance(item, dict) or not isinstance(item.get("key"), str) or not isinstance(item.get("value"), str):
|
|
316
|
+
raise YardError(400, "bad_json", "bad json")
|
|
317
|
+
parsed.append(Label(item["key"], item["value"]))
|
|
318
|
+
return title.strip(), text, parsed
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _filter_exps(rows: list[Experiment], pairs: list[tuple[str, str]]) -> list[Experiment]:
|
|
322
|
+
want: dict[str, list[str]] = {}
|
|
323
|
+
for key, value in pairs:
|
|
324
|
+
if key == "id" or not value:
|
|
325
|
+
continue
|
|
326
|
+
want.setdefault(key, []).append(value)
|
|
327
|
+
if not want:
|
|
328
|
+
return rows
|
|
329
|
+
|
|
330
|
+
def matches(exp: Experiment) -> bool:
|
|
331
|
+
for key, values in want.items():
|
|
332
|
+
for value in values:
|
|
333
|
+
if not any(label.key == key and label.value == value for label in exp.labels):
|
|
334
|
+
return False
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
return [row for row in rows if matches(row)]
|