Skip to content

Commit 5f01c12

Browse files
committed
chore: auto registry runner to github
1 parent b1e582b commit 5f01c12

File tree

6 files changed

+220
-55
lines changed

6 files changed

+220
-55
lines changed

docker-compose.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# 基于 docs/docker.md 中「方式二:DinD」编排
2+
# 使用前请确保 config.yaml 中 runners.base_path 为 /app/runners
3+
4+
services:
5+
runner-dind:
6+
image: docker:dind
7+
container_name: runner-dind
8+
privileged: true
9+
environment:
10+
DOCKER_TLS_CERTDIR: ""
11+
# 显式关闭 TLS,仅在内网 runner-net 使用,消除弃用告警
12+
command: ["--tls=false"]
13+
networks:
14+
- runner-net
15+
restart: unless-stopped
16+
17+
runner-manager:
18+
image: ghcr.io/soulteary/runner-fleet:main
19+
container_name: runner-manager
20+
depends_on:
21+
- runner-dind
22+
environment:
23+
DOCKER_HOST: tcp://runner-dind:2375
24+
ports:
25+
- "8080:8080"
26+
volumes:
27+
- ./config.yaml:/app/config.yaml
28+
- ./runners:/app/runners
29+
networks:
30+
- runner-net
31+
restart: unless-stopped
32+
33+
networks:
34+
runner-net:
35+
driver: bridge

docs/adding-runner.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@
1616

1717
若与已有 Runner 同名,会提示不可重复。
1818

19+
### 从 GitHub 命令一键填充
20+
21+
在 GitHub 的「New self-hosted runner」页面会看到类似命令:
22+
23+
```bash
24+
./config.sh --url https://github.com/owner/repo --token YOUR_TOKEN
25+
```
26+
27+
在管理界面「快速添加 Runner」上方的**从 GitHub 复制命令解析**框中粘贴整行命令,点击「解析并填充」,会自动识别并填入**目标类型****目标****注册 Token**,并建议**名称**(仓库名或组织名),无需手动拆分 URL 与 Token。
28+
29+
**说明**:自动注册(填写 Token 并提交)时,服务会向 **GitHub.com** 发起注册;若你使用的是 GitHub Enterprise,请勿在界面使用「自动注册」,需在对应 runner 目录下手动执行 `config.sh --url <你的 Enterprise URL> --token <TOKEN>`
30+
1931
在界面中**编辑**已有 Runner 的配置(目标、标签等)并保存后,若该 Runner 已注册且当前未在运行,也会**自动启动**,无需再手动点击「启动」。
2032

2133
## 3. Runner 程序未安装时
@@ -28,7 +40,7 @@
2840
./config.sh --url https://github.com/owner/repo --token <TOKEN>
2941
```
3042

31-
**使用 Docker 或 DinD 时**镜像内没有 runner 二进制,添加 Runner 时只会创建空目录。若希望提交表单时自动完成注册,可**在容器内执行安装脚本**[Docker 部署](docker.md) 中「Docker/DinD 下自动注册的前提」)完成解压后再在界面添加;或先在宿主机把 runner 解压到挂载的 `runners/<名称>/` 下,再在界面填写并提交(名称、目标、Token)。若已先提交导致目录为空,请在该目录解压 runner 后,到 GitHub 重新获取 Token,在目录下手动执行上述 `config.sh`
43+
**使用 Docker 或 DinD 时**若在「快速添加」中填写了 Token 并提交,服务会**先自动执行安装脚本**下载并解压 runner 到该目录),再执行注册并启动;无需事先手动执行 `install-runner.sh`。若自动安装失败(如网络问题),可按 [Docker 部署](docker.md) 中「Docker/DinD 下自动注册的前提」手动安装后,重新获取 Token 再在界面提交或在该目录下手动执行 `config.sh`
3244

3345
## 一台机器多 Runner
3446

docs/docker.md

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,38 +83,26 @@ docker run -d --name runner-manager \
8383
- Runner 子进程会继承 `DOCKER_HOST`,Job 中的 `docker` 命令将使用 `runner-dind` 容器内的守护进程。
8484
- DinD 需 `--privileged`,且与宿主机 Docker 隔离,适合希望 Job 与宿主机环境隔离的场景。
8585

86-
#### Docker/DinD 下「自动注册」的前提
86+
#### Docker/DinD 下「自动注册」
8787

88-
本镜像**不包含** GitHub Actions runner 二进制。添加 Runner 时若在表单中填写了 Token,服务只会在**已存在 runner 程序的目录**里执行 `config.sh` 完成注册;若该目录为空(刚由服务创建),则只会保存配置并返回提示「请将 runner 解压到某目录后再次提交或手动执行 config.sh
88+
本镜像**不包含** GitHub Actions runner 二进制,但**支持在 Web 界面一次完成安装与注册**:在「快速添加 Runner」中填写名称、目标、Token 并提交后,服务会先自动执行 `/app/scripts/install-runner.sh` 下载并解压 runner,再执行 `config.sh` 向 GitHub 注册并启动 Runner。无需事先手动安装
8989

90-
因此在使用 Docker/DinD 时,若希望**一次提交就完成注册**,请按以下顺序操作
90+
若自动安装失败(例如网络不可达 GitHub releases),可改为手动安装后再在界面提交 Token,或手动执行注册
9191

92-
1. **方式 A:容器内使用安装脚本**(推荐)
93-
在宿主机执行以下命令,会在挂载的 `runners/<名称>/` 下自动下载并解压 runner(默认版本 2.331.0,可选校验哈希):
92+
1. **容器内执行安装脚本**(推荐)
9493
```bash
9594
docker exec runner-manager /app/scripts/install-runner.sh <名称> [版本号]
9695
```
97-
`config.yaml``runners.base_path` 不是 `/app/runners`可设置环境变量后执行,例如
96+
`config.yaml``runners.base_path` 不是 `/app/runners`可设置环境变量
9897
```bash
9998
docker exec -e RUNNERS_BASE_PATH=/app/runners runner-manager /app/scripts/install-runner.sh my-runner
10099
```
100+
2. **或在宿主机**创建 `runners/<名称>/` 并解压 [actions-runner Linux 包](https://github.com/actions/runner/releases),再在界面填写名称、目标、Token 提交。
101101

102-
2. **方式 B:在宿主机**(挂载 `runners` 的那台机器)上,先创建对应子目录并解压 runner:
103-
```bash
104-
mkdir -p runners/<名称>
105-
cd runners/<名称>
106-
# 从 https://github.com/actions/runner/releases 下载 Linux x64 包并解压到当前目录
107-
curl -sL https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz | tar xz
108-
```
109-
3. 再在管理界面「快速添加 Runner」中填写名称(与上面使用的 `<名称>` 一致)、目标、Token 并提交,此时服务会检测到 `config.sh` 并自动执行注册。
110-
111-
若已先在界面添加了 Runner(目录已创建但为空),可:
112-
113-
- 在宿主机进入 `runners/<名称>/`,解压 runner 后,到 GitHub 重新获取一份 Token,在该目录下手动执行:
102+
若已先在界面添加了 Runner(目录已创建但为空),可删除该 Runner 后重新在界面填写 Token 提交(会触发自动安装),或在目录内解压 runner 后到 GitHub 重新获取 Token,手动执行:
114103
```bash
115104
./config.sh --url https://github.com/<目标> --token <新TOKEN>
116105
```
117-
- 或删除该 Runner 配置后,按上面 1/2→3 顺序重新添加(可改用方式 A 在容器内执行安装脚本)。
118106

119107
---
120108

internal/handler/handler.go

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
package handler
22

33
import (
4+
"context"
45
"crypto/rand"
56
"net/http"
67
"os"
78
"os/exec"
89
"path/filepath"
910
"strings"
11+
"time"
1012

1113
"github.com/lab-dev/github-actions-runner-manager/internal/config"
1214
"github.com/lab-dev/github-actions-runner-manager/internal/runner"
1315
"github.com/labstack/echo/v4"
1416
)
1517

18+
// installRunnerScriptPath 容器内自动安装 runner 的脚本路径(Docker 镜像中有)
19+
const installRunnerScriptPath = "/app/scripts/install-runner.sh"
20+
1621
// ConfigPath 配置文件路径,由 main 注入
1722
var ConfigPath string
1823

@@ -48,6 +53,19 @@ func runnerNameExists(cfg *config.Config, name string) bool {
4853
return false
4954
}
5055

56+
// runInstallRunnerScript 执行容器内 install-runner.sh,下载并解压 runner 到 basePath/runnerName;超时返回 error
57+
func runInstallRunnerScript(basePath, runnerName string, timeout time.Duration) ([]byte, error) {
58+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
59+
defer cancel()
60+
cmd := exec.CommandContext(ctx, installRunnerScriptPath, runnerName)
61+
cmd.Env = append(os.Environ(), "RUNNERS_BASE_PATH="+basePath)
62+
out, err := cmd.CombinedOutput()
63+
if ctx.Err() == context.DeadlineExceeded {
64+
return out, context.DeadlineExceeded
65+
}
66+
return out, err
67+
}
68+
5169
// Health 健康检查,供负载均衡或 K8s 探针使用
5270
func Health(c echo.Context) error {
5371
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
@@ -151,11 +169,35 @@ func AddRunner(c echo.Context) error {
151169
// 在 installDir 执行 config 脚本
152170
configScript := filepath.Join(installDir, runner.ConfigScriptName())
153171
if _, err := os.Stat(configScript); err != nil {
154-
return c.JSON(http.StatusOK, map[string]any{
155-
"message": "配置已保存,Runner 目录已创建。请将 GitHub Actions runner 解压到 " + installDir + " 后,使用注册 token 再次提交或在该目录下手动执行 " + runner.ConfigScriptName(),
156-
"name": item.Name,
157-
"install_dir": installDir,
158-
})
172+
// 目录为空时(如 Docker 部署):若存在自动安装脚本则先安装 runner 再注册
173+
if _, scriptErr := os.Stat(installRunnerScriptPath); scriptErr == nil {
174+
runnerSegment := filepath.Base(installDir)
175+
installOut, installErr := runInstallRunnerScript(cfg.Runners.BasePath, runnerSegment, 3*time.Minute)
176+
if installErr != nil {
177+
return c.JSON(http.StatusOK, map[string]any{
178+
"message": "配置已保存,自动安装 Runner 失败(请检查网络或稍后手动安装): " + installErr.Error(),
179+
"name": item.Name,
180+
"install_dir": installDir,
181+
"output": string(installOut),
182+
})
183+
}
184+
// 安装后再次检查 config 脚本
185+
if _, err2 := os.Stat(configScript); err2 != nil {
186+
return c.JSON(http.StatusOK, map[string]any{
187+
"message": "配置已保存,Runner 已安装但未找到 " + runner.ConfigScriptName() + ",请检查 " + installDir,
188+
"name": item.Name,
189+
"install_dir": installDir,
190+
"output": string(installOut),
191+
})
192+
}
193+
// 继续执行下方的 config 与启动逻辑
194+
} else {
195+
return c.JSON(http.StatusOK, map[string]any{
196+
"message": "配置已保存,Runner 目录已创建。请将 GitHub Actions runner 解压到 " + installDir + " 后,使用注册 token 再次提交或在该目录下手动执行 " + runner.ConfigScriptName(),
197+
"name": item.Name,
198+
"install_dir": installDir,
199+
})
200+
}
159201
}
160202
url := "https://github.com/" + req.Target
161203
cmd := exec.Command(configScript, "--url", url, "--token", req.RegistrationToken)
@@ -192,29 +234,6 @@ func AddRunner(c echo.Context) error {
192234
})
193235
}
194236

195-
// RemoveRunnerRequest 删除 runner 请求(仅从配置移除,不执行 remove-token)
196-
type RemoveRunnerRequest struct {
197-
Name string `json:"name" form:"name"`
198-
}
199-
200-
// RemoveRunner 从配置中移除 runner(POST body 或 form 提供 name)
201-
func RemoveRunner(c echo.Context) error {
202-
var req RemoveRunnerRequest
203-
_ = c.Bind(&req)
204-
if req.Name == "" {
205-
return echo.NewHTTPError(http.StatusBadRequest, "请提供 name")
206-
}
207-
if err := config.LoadAndSave(ConfigPath, func(cfg *config.Config) error {
208-
return removeRunnerFromConfig(cfg, req.Name)
209-
}); err != nil {
210-
if he, ok := err.(*echo.HTTPError); ok {
211-
return he
212-
}
213-
return echo.NewHTTPError(http.StatusInternalServerError, "保存配置失败")
214-
}
215-
return c.JSON(http.StatusOK, map[string]any{"message": "已从配置中移除"})
216-
}
217-
218237
// GetRunner 查看单个 runner 配置与状态(GET /api/runners/:name)
219238
func GetRunner(c echo.Context) error {
220239
cfg, err := getConfig(c)

internal/runner/runner.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ func RunScriptName() string {
159159
return "run.sh"
160160
}
161161

162-
// readRunnerPid 读取 runner 的 pid 文件(Runner.Listener.pid 或 .path),返回 pid,无效则 0 与 error
162+
// readRunnerPid 读取 runner 的 pid 文件,返回 pid,无效则 0 与 error。
163+
// Runner.Listener.pid 为官方 runner 使用;.path 为部分版本或兼容用途。
163164
func readRunnerPid(installDir string) (int, error) {
164165
for _, name := range []string{"Runner.Listener.pid", ".path"} {
165166
f := filepath.Join(installDir, name)

templates/index.html

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,40 @@
124124
.msg.ok { background: rgba(63, 185, 80, 0.15); color: var(--success); }
125125
.msg.err { background: rgba(248, 81, 73, 0.15); color: var(--danger); }
126126
.path { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); }
127+
.parse-box {
128+
margin-bottom: 20px;
129+
padding-bottom: 20px;
130+
border-bottom: 1px solid var(--border);
131+
}
132+
.parse-box label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 13px; }
133+
.parse-box code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
134+
.parse-row { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; }
135+
.parse-row textarea {
136+
flex: 1;
137+
min-width: 280px;
138+
padding: 10px 12px;
139+
background: var(--bg);
140+
border: 1px solid var(--border);
141+
border-radius: 6px;
142+
color: var(--text);
143+
font-family: ui-monospace, monospace;
144+
font-size: 13px;
145+
resize: vertical;
146+
}
147+
.btn-parse {
148+
padding: 10px 16px;
149+
background: var(--accent);
150+
color: var(--bg);
151+
border: none;
152+
border-radius: 6px;
153+
cursor: pointer;
154+
font-weight: 600;
155+
white-space: nowrap;
156+
}
157+
.btn-parse:hover { opacity: 0.9; }
158+
.parse-msg { margin-top: 8px; font-size: 13px; }
159+
.parse-msg.ok { color: var(--success); }
160+
.parse-msg.err { color: var(--danger); }
127161
</style>
128162
</head>
129163
<body>
@@ -169,22 +203,30 @@ <h2>Runner 列表</h2>
169203

170204
<div class="card">
171205
<h2>快速添加 Runner</h2>
206+
<div class="parse-box">
207+
<label>从 GitHub 复制命令解析(粘贴 <code>./config.sh --url ... --token ...</code> 后点击解析)</label>
208+
<div class="parse-row">
209+
<textarea id="githubCommandInput" rows="3" placeholder="./config.sh --url https://github.com/owner/repo --token YOUR_TOKEN"></textarea>
210+
<button type="button" id="parseCommandBtn" class="btn-parse">解析并填充</button>
211+
</div>
212+
<div id="parseMsg" class="parse-msg"></div>
213+
</div>
172214
<form id="addForm">
173215
<label>名称 (name) *</label>
174-
<input name="name" required placeholder="例如 runner-1">
216+
<input name="name" id="addFormName" required placeholder="例如 runner-1">
175217
<label>子路径 (path,可选,默认用名称)</label>
176-
<input name="path" placeholder="例如 runner-1">
218+
<input name="path" id="addFormPath" placeholder="例如 runner-1">
177219
<label>目标类型 (target_type) *</label>
178-
<select name="target_type" required>
220+
<select name="target_type" id="addFormTargetType" required>
179221
<option value="org">组织 (org)</option>
180222
<option value="repo">仓库 (repo)</option>
181223
</select>
182224
<label>目标 (target) *</label>
183-
<input name="target" required placeholder="组织名 或 owner/repo">
225+
<input name="target" id="addFormTarget" required placeholder="组织名 或 owner/repo">
184226
<label>标签 (labels,逗号分隔,可选)</label>
185-
<input name="labelsStr" placeholder="self-hosted, linux, x64">
227+
<input name="labelsStr" id="addFormLabelsStr" placeholder="self-hosted, linux, x64">
186228
<label>注册 Token(可选,从 GitHub 设置 → Actions → Runners 复制,1 小时有效)</label>
187-
<input name="registration_token" type="password" placeholder="选填,有则自动执行注册">
229+
<input name="registration_token" id="addFormToken" type="password" placeholder="选填;填写后将自动安装(Docker 下)并注册、启动">
188230
<button type="submit">添加 Runner</button>
189231
</form>
190232
<div id="addMsg"></div>
@@ -229,6 +271,74 @@ <h3 id="modalTitle">Runner 配置</h3>
229271
</div>
230272

231273
<script>
274+
// 解析 GitHub config.sh 命令,填充到添加 Runner 表单
275+
// 与后端 isValidNameOrPath 一致:不允许含 .. / \
276+
function sanitizeRunnerName(s) {
277+
if (!s || typeof s !== 'string') return '';
278+
return s.replace(/\.\./g, '').replace(/[/\\]/g, '-').trim() || 'runner';
279+
}
280+
281+
function parseGitHubConfigCommand(text) {
282+
if (!text || typeof text !== 'string') return { ok: false, message: '请输入命令' };
283+
const trimmed = text.trim();
284+
// 匹配 --url <URL> 和 --token <TOKEN>(兼容多余空格、换行、引号)
285+
const urlMatch = trimmed.match(/\-\-url\s+["']?([^\s"']+)["']?/);
286+
const tokenMatch = trimmed.match(/\-\-token\s+["']?([^\s"']+)["']?/);
287+
if (!urlMatch) return { ok: false, message: '未找到 --url 参数' };
288+
const url = urlMatch[1].trim();
289+
let parsedUrl;
290+
try {
291+
parsedUrl = new URL(url);
292+
} catch (e) {
293+
return { ok: false, message: 'URL 格式无效' };
294+
}
295+
// pathname 不含 query/hash,支持 GitHub 与 GitHub Enterprise
296+
const pathParts = parsedUrl.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
297+
let targetType = 'org';
298+
let target = '';
299+
let suggestedName = '';
300+
if (pathParts.length >= 2) {
301+
targetType = 'repo';
302+
target = pathParts[0] + '/' + pathParts[1];
303+
suggestedName = sanitizeRunnerName(pathParts[1]);
304+
} else if (pathParts.length === 1) {
305+
target = pathParts[0];
306+
suggestedName = sanitizeRunnerName(pathParts[0]);
307+
} else {
308+
return { ok: false, message: '无法从 URL 解析出组织或仓库' };
309+
}
310+
const token = tokenMatch ? tokenMatch[1].trim() : '';
311+
return {
312+
ok: true,
313+
target_type: targetType,
314+
target: target,
315+
registration_token: token,
316+
suggested_name: suggestedName || 'runner'
317+
};
318+
}
319+
320+
document.getElementById('parseCommandBtn').addEventListener('click', function() {
321+
const input = document.getElementById('githubCommandInput');
322+
const msgEl = document.getElementById('parseMsg');
323+
const result = parseGitHubConfigCommand(input.value);
324+
msgEl.textContent = '';
325+
msgEl.className = 'parse-msg';
326+
if (!result.ok) {
327+
msgEl.className = 'parse-msg err';
328+
msgEl.textContent = result.message;
329+
return;
330+
}
331+
document.getElementById('addFormTargetType').value = result.target_type;
332+
document.getElementById('addFormTarget').value = result.target;
333+
document.getElementById('addFormToken').value = result.registration_token || '';
334+
if (result.suggested_name && !document.getElementById('addFormName').value) {
335+
document.getElementById('addFormName').value = result.suggested_name;
336+
document.getElementById('addFormPath').value = '';
337+
}
338+
msgEl.className = 'parse-msg ok';
339+
msgEl.textContent = '已填充:目标类型 ' + result.target_type + ',目标 ' + result.target + (result.registration_token ? ',Token 已填入' : '');
340+
});
341+
232342
document.getElementById('addForm').addEventListener('submit', async (e) => {
233343
e.preventDefault();
234344
const fd = new FormData(e.target);

0 commit comments

Comments
 (0)