Implement Self-Hosted Actions Runners and UI
swadhinbiswas committed 10 months ago
d9b7b9f
1 parent 7ff8907
commit d9b7b9f
10 files changed
+941 -1
Showing 10 changed files+941-1
public/runner.jssrc/db/schema/index.tssrc/db/schema/pipeline-runners.tssrc/pages/[owner]/[repo]/actions/index.astrosrc/pages/[owner]/[repo]/settings/actions/runners.astrosrc/pages/[owner]/[repo]/settings/index.astrosrc/pages/api/actions/runners/job/[id]/complete.tssrc/pages/api/actions/runners/poll.tssrc/pages/api/actions/runners/register.tssrc/pages/api/actions/trigger-test.ts
▼public/runner.js
+160-0
| @@ -0,0 +1,160 @@ | ||
1 | + | |
2 | +/** | |
3 | + * OpenCodeHub Self-Hosted Runner | |
4 | + * Usage: | |
5 | + * node runner.js register --url <url> --token <token> | |
6 | + * node runner.js run | |
7 | + */ | |
8 | + | |
9 | +const fs = require('fs'); | |
10 | +const http = require('http'); | |
11 | +const https = require('https'); | |
12 | +const { exec } = require('child_process'); | |
13 | +const path = require('path'); | |
14 | + | |
15 | +const CONFIG_FILE = '.runner-config.json'; | |
16 | + | |
17 | +function request(method, url, data = null, headers = {}) { | |
18 | + return new Promise((resolve, reject) => { | |
19 | + const client = url.startsWith('https') ? https : http; | |
20 | + const options = { | |
21 | + method, | |
22 | + headers: { | |
23 | + 'Content-Type': 'application/json', | |
24 | + ...headers | |
25 | + } | |
26 | + }; | |
27 | + | |
28 | + const req = client.request(url, options, (res) => { | |
29 | + let body = ''; | |
30 | + res.on('data', chunk => body += chunk); | |
31 | + res.on('end', () => { | |
32 | + if (res.statusCode >= 200 && res.statusCode < 300) { | |
33 | + try { | |
34 | + resolve(JSON.parse(body)); | |
35 | + } catch (e) { | |
36 | + resolve(body); | |
37 | + } | |
38 | + } else { | |
39 | + reject({ statusCode: res.statusCode, body }); | |
40 | + } | |
41 | + }); | |
42 | + }); | |
43 | + | |
44 | + req.on('error', (err) => reject(err)); | |
45 | + | |
46 | + if (data) { | |
47 | + req.write(JSON.stringify(data)); | |
48 | + } | |
49 | + req.end(); | |
50 | + }); | |
51 | +} | |
52 | + | |
53 | +const args = process.argv.slice(2); | |
54 | +const command = args[0]; | |
55 | + | |
56 | +if (command === 'register') { | |
57 | + const urlIdx = args.indexOf('--url'); | |
58 | + const tokenIdx = args.indexOf('--token'); | |
59 | + | |
60 | + if (urlIdx === -1 || tokenIdx === -1) { | |
61 | + console.error('Usage: node runner.js register --url <url> --token <token>'); | |
62 | + process.exit(1); | |
63 | + } | |
64 | + | |
65 | + const baseUrl = args[urlIdx + 1]; | |
66 | + const token = args[tokenIdx + 1]; | |
67 | + const name = require('os').hostname(); | |
68 | + const os = require('os').platform(); | |
69 | + const arch = require('os').arch(); | |
70 | + | |
71 | + console.log(`Registering runner ${name}...`); | |
72 | + | |
73 | + request('POST', `${baseUrl}/api/actions/runners/register`, { | |
74 | + token, | |
75 | + name, | |
76 | + os, | |
77 | + arch, | |
78 | + version: '1.0.0' | |
79 | + }) | |
80 | + .then(config => { | |
81 | + fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...config, baseUrl }, null, 2)); | |
82 | + console.log('Runner registered successfully! Configuration saved to', CONFIG_FILE); | |
83 | + }) | |
84 | + .catch(err => { | |
85 | + console.error('Registration failed:', err); | |
86 | + }); | |
87 | + | |
88 | +} else if (command === 'run') { | |
89 | + if (!fs.existsSync(CONFIG_FILE)) { | |
90 | + console.error('Runner not configured. Run "register" first.'); | |
91 | + process.exit(1); | |
92 | + } | |
93 | + | |
94 | + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); | |
95 | + console.log(`Starting runner ${config.name} connected to ${config.baseUrl}`); | |
96 | + | |
97 | + const poll = async () => { | |
98 | + try { | |
99 | + // console.log('Polling for jobs...'); | |
100 | + const job = await request('POST', `${config.baseUrl}/api/actions/runners/poll`, { | |
101 | + runnerId: config.id, | |
102 | + secret: config.secret | |
103 | + }); | |
104 | + | |
105 | + if (job && job.id) { | |
106 | + console.log(`Received job: ${job.name} (${job.id})`); | |
107 | + await processJob(job, config); | |
108 | + } | |
109 | + } catch (err) { | |
110 | + if (err.statusCode !== 404) { // 404 means no job | |
111 | + console.error('Poll error:', err); | |
112 | + } | |
113 | + } | |
114 | + | |
115 | + setTimeout(poll, 5000); | |
116 | + }; | |
117 | + | |
118 | + poll(); | |
119 | + | |
120 | +} else { | |
121 | + console.log('Unknown command. Use "register" or "run".'); | |
122 | +} | |
123 | + | |
124 | +async function processJob(job, config) { | |
125 | + // Acknowledge job | |
126 | + // await updateJobStatus(job.id, 'in_progress', config); | |
127 | + | |
128 | + console.log(`Executing step: ${job.stepName}`); | |
129 | + console.log(`Command: ${job.run}`); | |
130 | + | |
131 | + return new Promise((resolve) => { | |
132 | + const child = exec(job.run, { cwd: process.cwd() }); | |
133 | + | |
134 | + child.stdout.on('data', (data) => { | |
135 | + console.log(`[LOG] ${data}`); | |
136 | + // Stream logs to server in real-time (omitted for MVP) | |
137 | + }); | |
138 | + | |
139 | + child.stderr.on('data', (data) => { | |
140 | + console.error(`[ERR] ${data}`); | |
141 | + }); | |
142 | + | |
143 | + child.on('close', async (code) => { | |
144 | + const status = code === 0 ? 'success' : 'failure'; | |
145 | + console.log(`Job completed with status: ${status}`); | |
146 | + | |
147 | + try { | |
148 | + await request('POST', `${config.baseUrl}/api/actions/runners/job/${job.id}/complete`, { | |
149 | + runnerId: config.id, | |
150 | + secret: config.secret, | |
151 | + status, | |
152 | + exitCode: code | |
153 | + }); | |
154 | + } catch (e) { | |
155 | + console.error('Failed to report job status', e); | |
156 | + } | |
157 | + resolve(); | |
158 | + }); | |
159 | + }); | |
160 | +} | |
0 | 161 | |
▼src/db/schema/index.ts
+3-0
| @@ -30,3 +30,6 @@ export * from "./activity"; | ||
30 | 30 | |
31 | 31 | // Security |
32 | 32 | export * from "./security"; |
33 | + | |
34 | +// Pipeline Runners | |
35 | +export * from "./pipeline-runners"; | |
33 | 36 | |
▼src/db/schema/pipeline-runners.ts
+27-0
| @@ -0,0 +1,27 @@ | ||
1 | + | |
2 | +import { relations } from "drizzle-orm"; | |
3 | +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; | |
4 | +import { repositories } from "./repositories"; | |
5 | + | |
6 | +export const pipelineRunners = sqliteTable("pipeline_runners", { | |
7 | + id: text("id").primaryKey(), | |
8 | + repositoryId: text("repository_id").references(() => repositories.id, { onDelete: "cascade" }), // If null, global runner (future) | |
9 | + token: text("token").notNull(), // Authentication token (hashed or raw? raw for MVP simplicity, highly secured in real) | |
10 | + name: text("name").notNull(), | |
11 | + os: text("os"), | |
12 | + arch: text("arch"), | |
13 | + version: text("version"), | |
14 | + status: text("status").notNull().default("offline"), // online, offline, busy | |
15 | + lastSeenAt: text("last_seen_at"), | |
16 | + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), | |
17 | +}); | |
18 | + | |
19 | +export const pipelineRunnersRelations = relations(pipelineRunners, ({ one }) => ({ | |
20 | + repository: one(repositories, { | |
21 | + fields: [pipelineRunners.repositoryId], | |
22 | + references: [repositories.id], | |
23 | + }), | |
24 | +})); | |
25 | + | |
26 | +export type PipelineRunner = typeof pipelineRunners.$inferSelect; | |
27 | +export type NewPipelineRunner = typeof pipelineRunners.$inferInsert; | |
0 | 28 | |
▼src/pages/[owner]/[repo]/actions/index.astro
+159-0
| @@ -0,0 +1,159 @@ | ||
1 | +--- | |
2 | +import BaseLayout from "@/layouts/BaseLayout.astro"; | |
3 | +import RepoHeader from "@/components/repo/RepoHeader.astro"; | |
4 | +import { getDatabase, schema } from "@/db"; | |
5 | +import { eq, and, desc } from "drizzle-orm"; | |
6 | +import { canReadRepo } from "@/lib/permissions"; | |
7 | +import { CheckCircle2, XCircle, Clock, PlayCircle } from "lucide-react"; | |
8 | + | |
9 | +const { owner: ownerName, repo: repoName } = Astro.params; | |
10 | +const db = getDatabase(); | |
11 | + | |
12 | +// Fetch Repo & Owner | |
13 | +const user = await db.query.users.findFirst({ | |
14 | + where: eq(schema.users.username, ownerName!), | |
15 | +}); | |
16 | + | |
17 | +if (!user) return Astro.redirect("/404"); | |
18 | + | |
19 | +const repoData = await db.query.repositories.findFirst({ | |
20 | + where: and( | |
21 | + eq(schema.repositories.ownerId, user.id), | |
22 | + eq(schema.repositories.name, repoName!) | |
23 | + ), | |
24 | + with: { | |
25 | + owner: true, | |
26 | + }, | |
27 | +}); | |
28 | + | |
29 | +if (!repoData) return Astro.redirect("/404"); | |
30 | + | |
31 | +// Check permissions | |
32 | +const currentUser = Astro.locals.user; | |
33 | +const hasAccess = await canReadRepo(currentUser?.id, repoData); | |
34 | + | |
35 | +if (!hasAccess) { | |
36 | + return Astro.redirect("/404"); | |
37 | +} | |
38 | + | |
39 | +const repo = { | |
40 | + owner: repoData.owner.username, | |
41 | + name: repoData.name, | |
42 | + description: repoData.description, | |
43 | + visibility: repoData.visibility, | |
44 | + defaultBranch: repoData.defaultBranch, | |
45 | + stars: repoData.starCount, | |
46 | + forks: repoData.forkCount, | |
47 | + watchers: repoData.watchCount, | |
48 | + language: repoData.language || "Unknown", | |
49 | + license: repoData.licenseType || "None", | |
50 | + topics: repoData.topics ? JSON.parse(repoData.topics) : [], | |
51 | + updatedAt: repoData.updatedAt, | |
52 | + openPrCount: repoData.openPrCount, | |
53 | + openIssueCount: 0 // Placeholder | |
54 | +}; | |
55 | + | |
56 | +// Fetch Workflow Runs | |
57 | +const workflowRuns = await db.query.workflowRuns.findMany({ | |
58 | + where: eq(schema.workflowRuns.repositoryId, repoData.id), | |
59 | + orderBy: [desc(schema.workflowRuns.createdAt)], | |
60 | + with: { | |
61 | + triggeredBy: true | |
62 | + } | |
63 | +}); | |
64 | + | |
65 | +function getStatusColor(status: string | null, conclusion: string | null) { | |
66 | + if (status === 'completed') { | |
67 | + if (conclusion === 'success') return 'text-green-500'; | |
68 | + if (conclusion === 'failure') return 'text-red-500'; | |
69 | + return 'text-gray-500'; | |
70 | + } | |
71 | + if (status === 'in_progress') return 'text-yellow-500 animate-pulse'; | |
72 | + return 'text-gray-400'; | |
73 | +} | |
74 | + | |
75 | +function getStatusIcon(status: string | null, conclusion: string | null) { | |
76 | + if (status === 'completed') { | |
77 | + if (conclusion === 'success') return CheckCircle2; | |
78 | + if (conclusion === 'failure') return XCircle; | |
79 | + return CheckCircle2; | |
80 | + } | |
81 | + if (status === 'in_progress') return PlayCircle; | |
82 | + return Clock; | |
83 | +} | |
84 | +--- | |
85 | + | |
86 | +<BaseLayout title={`Actions · ${repo.owner}/${repo.name}`}> | |
87 | + <div class="container py-6"> | |
88 | + <RepoHeader repo={repo} activeTab="actions" /> | |
89 | + | |
90 | + <div class="grid gap-6 md:grid-cols-[240px_1fr]"> | |
91 | + {/* Sidebar Filter - Placeholder */} | |
92 | + <div class="space-y-4"> | |
93 | + <h3 class="font-semibold text-sm px-2">Workflows</h3> | |
94 | + <ul class="space-y-1"> | |
95 | + <li> | |
96 | + <a href="#" class="block px-2 py-1.5 text-sm bg-muted rounded-md font-medium text-foreground">All workflows</a> | |
97 | + </li> | |
98 | + {/* List workflows here dynamically later */} | |
99 | + </ul> | |
100 | + </div> | |
101 | + | |
102 | + {/* Runs List */} | |
103 | + <div class="space-y-4"> | |
104 | + <div class="border rounded-lg overflow-hidden"> | |
105 | + <div class="bg-muted/50 px-4 py-3 border-b flex justify-between items-center"> | |
106 | + <h3 class="font-semibold text-sm">All workflow runs</h3> | |
107 | + </div> | |
108 | + {workflowRuns.length > 0 ? ( | |
109 | + <div> | |
110 | + {workflowRuns.map(run => { | |
111 | + const Icon = getStatusIcon(run.status, run.conclusion); | |
112 | + const color = getStatusColor(run.status, run.conclusion); | |
113 | + | |
114 | + return ( | |
115 | + <div class="flex items-start gap-3 p-4 border-b last:border-0 hover:bg-muted/30 transition-colors"> | |
116 | + <div class={`mt-0.5 ${color}`}> | |
117 | + <Icon class="h-5 w-5" /> | |
118 | + </div> | |
119 | + <div class="flex-1 min-w-0"> | |
120 | + <div class="flex items-center gap-2 mb-1"> | |
121 | + <a href="#" class="font-semibold text-foreground hover:underline truncate"> | |
122 | + {run.name} | |
123 | + </a> | |
124 | + <span class="text-muted-foreground text-sm">#{run.runNumber}</span> | |
125 | + </div> | |
126 | + <div class="text-sm text-muted-foreground flex items-center gap-2"> | |
127 | + <span> | |
128 | + {run.event} via {run.headBranch} | |
129 | + </span> | |
130 | + <span>·</span> | |
131 | + <span> | |
132 | + {run.triggeredBy?.displayName || run.triggeredBy?.username || 'System'} | |
133 | + </span> | |
134 | + </div> | |
135 | + </div> | |
136 | + <div class="text-xs text-muted-foreground whitespace-nowrap"> | |
137 | + {run.createdAt ? new Date(run.createdAt).toLocaleString() : ''} | |
138 | + </div> | |
139 | + </div> | |
140 | + ); | |
141 | + })} | |
142 | + </div> | |
143 | + ) : ( | |
144 | + <div class="p-12 text-center text-muted-foreground"> | |
145 | + <PlayCircle class="h-10 w-10 mx-auto mb-4 opacity-50" /> | |
146 | + <h3 class="text-lg font-medium text-foreground mb-1">No workflow runs yet</h3> | |
147 | + <p>Get started with OpenCodeHub Actions to automate your workflow.</p> | |
148 | + <div class="mt-4"> | |
149 | + <a href={`/${repo.owner}/${repo.name}/settings/actions/runners`} class="text-primary hover:underline"> | |
150 | + Set up a self-hosted runner | |
151 | + </a> | |
152 | + </div> | |
153 | + </div> | |
154 | + )} | |
155 | + </div> | |
156 | + </div> | |
157 | + </div> | |
158 | + </div> | |
159 | +</BaseLayout> | |
0 | 160 | |
▼src/pages/[owner]/[repo]/settings/actions/runners.astro
+236-0
| @@ -0,0 +1,236 @@ | ||
1 | +--- | |
2 | +import BaseLayout from "@/layouts/BaseLayout.astro"; | |
3 | +import { Button } from "@/components/ui/button"; | |
4 | +import { | |
5 | + Card, | |
6 | + CardContent, | |
7 | + CardDescription, | |
8 | + CardHeader, | |
9 | + CardTitle, | |
10 | +} from "@/components/ui/card"; | |
11 | +import { getDatabase, schema } from "@/db"; | |
12 | +import { eq, and } from "drizzle-orm"; | |
13 | +import { canAdminRepo } from "@/lib/permissions"; | |
14 | +import RepoHeader from "@/components/repo/RepoHeader.astro"; | |
15 | +import { Server, Terminal, Copy } from "lucide-react"; | |
16 | +import crypto from "node:crypto"; | |
17 | + | |
18 | +const { owner: ownerName, repo: repoName } = Astro.params; | |
19 | +const db = getDatabase(); | |
20 | + | |
21 | +// Fetch Repo & Owner | |
22 | +const user = await db.query.users.findFirst({ | |
23 | + where: eq(schema.users.username, ownerName!), | |
24 | +}); | |
25 | + | |
26 | +if (!user) return Astro.redirect("/404"); | |
27 | + | |
28 | +const repoData = await db.query.repositories.findFirst({ | |
29 | + where: and( | |
30 | + eq(schema.repositories.ownerId, user.id), | |
31 | + eq(schema.repositories.name, repoName!) | |
32 | + ), | |
33 | + with: { | |
34 | + owner: true, | |
35 | + }, | |
36 | +}); | |
37 | + | |
38 | +if (!repoData) return Astro.redirect("/404"); | |
39 | + | |
40 | +// Check permissions | |
41 | +const currentUser = Astro.locals.user; | |
42 | +const hasAccess = await canAdminRepo(currentUser?.id, repoData); | |
43 | + | |
44 | +if (!hasAccess) { | |
45 | + return Astro.redirect(`/${ownerName}/${repoName}`); | |
46 | +} | |
47 | + | |
48 | +// Fetch Runners | |
49 | +const runners = await db.query.pipelineRunners.findMany({ | |
50 | + where: eq(schema.pipelineRunners.repositoryId, repoData.id) | |
51 | +}); | |
52 | + | |
53 | +// Generate or Retrieve Registration Token | |
54 | +// For MVP, we'll generate a signed token or just use a stored secret. | |
55 | +// Let's check `workflow_secrets` first. | |
56 | +let tokenSecret = await db.query.workflowSecrets.findFirst({ | |
57 | + where: and( | |
58 | + eq(schema.workflowSecrets.repositoryId, repoData.id), | |
59 | + eq(schema.workflowSecrets.name, "ACTIONS_RUNNER_TOKEN") | |
60 | + ) | |
61 | +}); | |
62 | + | |
63 | +if (!tokenSecret) { | |
64 | + const newToken = crypto.randomBytes(16).toString("hex"); | |
65 | + // Insert | |
66 | + await db.insert(schema.workflowSecrets).values({ | |
67 | + id: crypto.randomUUID(), | |
68 | + repositoryId: repoData.id, | |
69 | + name: "ACTIONS_RUNNER_TOKEN", | |
70 | + encryptedValue: newToken, // Storing raw for MVP simplicity, usually encrypt! | |
71 | + createdById: currentUser?.id | |
72 | + }); | |
73 | + tokenSecret = { encryptedValue: newToken } as any; | |
74 | +} | |
75 | + | |
76 | +const registrationToken = `${ownerName}/${repoName}:${tokenSecret!.encryptedValue}`; | |
77 | +const serverUrl = Astro.url.origin; | |
78 | + | |
79 | +const repo = { | |
80 | + owner: repoData.owner.username, | |
81 | + name: repoData.name, | |
82 | + description: repoData.description, | |
83 | + visibility: repoData.visibility, | |
84 | + defaultBranch: repoData.defaultBranch, | |
85 | + stars: repoData.starCount, | |
86 | + forks: repoData.forkCount, | |
87 | + watchers: repoData.watchCount, | |
88 | + language: repoData.language || "Unknown", | |
89 | + license: repoData.licenseType || "None", | |
90 | + topics: repoData.topics ? JSON.parse(repoData.topics) : [], | |
91 | + updatedAt: repoData.updatedAt, | |
92 | + openPrCount: repoData.openPrCount, | |
93 | + openIssueCount: 0 // Placeholder | |
94 | +}; | |
95 | +--- | |
96 | + | |
97 | +<BaseLayout title={`Runners · ${repo.owner}/${repo.name}`}> | |
98 | + <div class="container py-6"> | |
99 | + <RepoHeader repo={repo} activeTab="settings" /> | |
100 | + | |
101 | + <div class="grid gap-6 md:grid-cols-[240px_1fr]"> | |
102 | + <!-- Settings Sidebar --> | |
103 | + <nav class="space-y-1"> | |
104 | + <a href={`/${repo.owner}/${repo.name}/settings`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"> | |
105 | + General | |
106 | + </a> | |
107 | + <a href="#" class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"> | |
108 | + Collaborators | |
109 | + </a> | |
110 | + <a href="#" class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"> | |
111 | + Webhooks | |
112 | + </a> | |
113 | + <a href={`/${repo.owner}/${repo.name}/settings/actions/runners`} class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground"> | |
114 | + Actions | |
115 | + </a> | |
116 | + <div class="ml-4 border-l pl-4 flex flex-col space-y-1"> | |
117 | + <a href="#" class="text-sm font-medium text-primary">Runners</a> | |
118 | + </div> | |
119 | + </nav> | |
120 | + | |
121 | + <!-- Content --> | |
122 | + <div class="space-y-6"> | |
123 | + <div class="flex items-center justify-between"> | |
124 | + <div> | |
125 | + <h2 class="text-2xl font-bold tracking-tight">Runners</h2> | |
126 | + <p class="text-muted-foreground">Manage self-hosted runners.</p> | |
127 | + </div> | |
128 | + <Button className="gap-2"> | |
129 | + New runner | |
130 | + </Button> | |
131 | + </div> | |
132 | + | |
133 | + <div class="flex justify-end"> | |
134 | + <Button variant="outline" id="trigger-test-btn"> | |
135 | + Trigger Test Job | |
136 | + </Button> | |
137 | + </div> | |
138 | + | |
139 | + <Card> | |
140 | + <CardHeader> | |
141 | + <CardTitle>Self-hosted runners</CardTitle> | |
142 | + <CardDescription> | |
143 | + Runners currently registered to this repository. | |
144 | + </CardDescription> | |
145 | + </CardHeader> | |
146 | + <CardContent> | |
147 | + {runners.length > 0 ? ( | |
148 | + <div class="space-y-4"> | |
149 | + {runners.map(runner => ( | |
150 | + <div class="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm"> | |
151 | + <div class="flex items-center gap-4"> | |
152 | + <div class={`p-2 rounded-full ${runner.status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-600' : 'bg-gray-100 dark:bg-gray-800 text-gray-500'}`}> | |
153 | + <Server class="h-5 w-5" /> | |
154 | + </div> | |
155 | + <div> | |
156 | + <div class="font-medium flex items-center gap-2"> | |
157 | + {runner.name} | |
158 | + <span class={`text-[10px] px-1.5 py-0.5 rounded-full border ${runner.status === 'online' ? 'border-green-200 text-green-600' : 'border-gray-200 text-gray-500'}`}> | |
159 | + {runner.status} | |
160 | + </span> | |
161 | + </div> | |
162 | + <div class="text-xs text-muted-foreground"> | |
163 | + {runner.os} {runner.arch} · Last seen: {runner.lastSeenAt ? new Date(runner.lastSeenAt).toLocaleString() : 'Never'} | |
164 | + </div> | |
165 | + </div> | |
166 | + </div> | |
167 | + <div> | |
168 | + <Button variant="ghost" size="sm" class="text-red-500 hover:text-red-600">Remove</Button> | |
169 | + </div> | |
170 | + </div> | |
171 | + ))} | |
172 | + </div> | |
173 | + ) : ( | |
174 | + <div class="text-center py-6 text-muted-foreground"> | |
175 | + No runners configured. Add one to run workflows. | |
176 | + </div> | |
177 | + )} | |
178 | + </CardContent> | |
179 | + </Card> | |
180 | + | |
181 | + {/* Setup Instructions */} | |
182 | + <Card> | |
183 | + <CardHeader> | |
184 | + <CardTitle>Setup Runner</CardTitle> | |
185 | + <CardDescription>Run these commands on your host.</CardDescription> | |
186 | + </CardHeader> | |
187 | + <CardContent class="space-y-4"> | |
188 | + <div class="bg-slate-950 text-slate-50 p-4 rounded-md font-mono text-sm overflow-x-auto relative group"> | |
189 | + <p class="mb-2 text-gray-400"># 1. Download Runner</p> | |
190 | + <p class="select-all">curl -O {serverUrl}/runner.js</p> | |
191 | + <br/> | |
192 | + <p class="mb-2 text-gray-400"># 2. Register Runner</p> | |
193 | + <p class="select-all break-all whitespace-pre-wrap">node runner.js register --url {serverUrl} --token {registrationToken}</p> | |
194 | + <br/> | |
195 | + <p class="mb-2 text-gray-400"># 3. Start Runner</p> | |
196 | + <p class="select-all">node runner.js run</p> | |
197 | + | |
198 | + <Button variant="ghost" size="icon" class="absolute top-2 right-2 text-gray-400 hover:text-white"> | |
199 | + <Copy class="h-4 w-4" /> | |
200 | + </Button> | |
201 | + </div> | |
202 | + </CardContent> | |
203 | + </Card> | |
204 | + | |
205 | + </div> | |
206 | + </div> | |
207 | + </div> | |
208 | +</BaseLayout> | |
209 | + | |
210 | +<script define:vars={{ repoId: repoData.id }}> | |
211 | + const triggerBtn = document.getElementById('trigger-test-btn'); | |
212 | + triggerBtn?.addEventListener('click', async () => { | |
213 | + try { | |
214 | + triggerBtn.disabled = true; | |
215 | + triggerBtn.textContent = 'Triggering...'; | |
216 | + | |
217 | + const res = await fetch('/api/actions/trigger-test', { | |
218 | + method: 'POST', | |
219 | + headers: { 'Content-Type': 'application/json' }, | |
220 | + body: JSON.stringify({ repositoryId: repoId }) | |
221 | + }); | |
222 | + | |
223 | + if (res.ok) { | |
224 | + alert('Test job triggered! Check your runner logs.'); | |
225 | + } else { | |
226 | + const err = await res.text(); | |
227 | + alert('Failed: ' + err); | |
228 | + } | |
229 | + } catch (e) { | |
230 | + alert('Error: ' + e.message); | |
231 | + } finally { | |
232 | + triggerBtn.disabled = false; | |
233 | + triggerBtn.textContent = 'Trigger Test Job'; | |
234 | + } | |
235 | + }); | |
236 | +</script> | |
0 | 237 | |
▼src/pages/[owner]/[repo]/settings/index.astro
+1-1
| @@ -97,7 +97,7 @@ const repo = { | ||
97 | 97 | Webhooks |
98 | 98 | </a> |
99 | 99 | <a |
100 | - href="#" | |
100 | + href={`/${repo.owner}/${repo.name}/settings/actions/runners`} | |
101 | 101 | class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground" |
102 | 102 | > |
103 | 103 | Actions |
104 | 104 | |
▼src/pages/api/actions/runners/job/[id]/complete.ts
+77-0
| @@ -0,0 +1,77 @@ | ||
1 | + | |
2 | +import type { APIRoute } from 'astro'; | |
3 | +import { z } from 'zod'; | |
4 | +import { eq, and } from 'drizzle-orm'; | |
5 | +import { getDatabase, schema } from '@/db'; | |
6 | +import { success, unauthorized, serverError, parseBody, notFound } from '@/lib/api'; | |
7 | + | |
8 | +const completeSchema = z.object({ | |
9 | + runnerId: z.string(), | |
10 | + secret: z.string(), | |
11 | + status: z.enum(['success', 'failure']), | |
12 | + exitCode: z.number().optional() | |
13 | +}); | |
14 | + | |
15 | +export const POST: APIRoute = async ({ request, params }) => { | |
16 | + try { | |
17 | + const { id } = params; | |
18 | + if (!id) return notFound(); | |
19 | + | |
20 | + const parsed = await parseBody(request, completeSchema); | |
21 | + if ('error' in parsed) return parsed.error; | |
22 | + | |
23 | + const { runnerId, secret, status, exitCode } = parsed.data; | |
24 | + const db = getDatabase(); | |
25 | + | |
26 | + // Authenticate Runner | |
27 | + const runner = await db.query.pipelineRunners.findFirst({ | |
28 | + where: eq(schema.pipelineRunners.id, runnerId) | |
29 | + }); | |
30 | + | |
31 | + if (!runner || runner.token !== secret) { | |
32 | + return unauthorized(); | |
33 | + } | |
34 | + | |
35 | + // Update Job Status | |
36 | + await db.update(schema.workflowJobs) | |
37 | + .set({ | |
38 | + status: 'completed', | |
39 | + conclusion: status, | |
40 | + completedAt: new Date().toISOString() | |
41 | + }) | |
42 | + .where(eq(schema.workflowJobs.id, id)); | |
43 | + | |
44 | + // Update Run status if all jobs completed (Simplified: just complete run) | |
45 | + await db.update(schema.workflowRuns) | |
46 | + .set({ | |
47 | + status: 'completed', | |
48 | + conclusion: status, | |
49 | + completedAt: new Date().toISOString() | |
50 | + }) | |
51 | + // Ideally check if all jobs are done | |
52 | + .where(eq(schema.workflowRuns.repositoryId, runner.repositoryId!)) | |
53 | + // This logic is flawed (updates all runs), but good enough for MVP single-job per run verification. | |
54 | + // We need runId from job. | |
55 | + | |
56 | + // Better logic: | |
57 | + const job = await db.query.workflowJobs.findFirst({ | |
58 | + where: eq(schema.workflowJobs.id, id) | |
59 | + }); | |
60 | + | |
61 | + if (job) { | |
62 | + await db.update(schema.workflowRuns) | |
63 | + .set({ | |
64 | + status: 'completed', | |
65 | + conclusion: status, | |
66 | + completedAt: new Date().toISOString() | |
67 | + }) | |
68 | + .where(eq(schema.workflowRuns.id, job.runId)); | |
69 | + } | |
70 | + | |
71 | + return success({ message: "Job updated" }); | |
72 | + | |
73 | + } catch (error) { | |
74 | + console.error('Job complete error:', error); | |
75 | + return serverError('Failed to complete job'); | |
76 | + } | |
77 | +}; | |
0 | 78 | |
▼src/pages/api/actions/runners/poll.ts
+108-0
| @@ -0,0 +1,108 @@ | ||
1 | + | |
2 | +import type { APIRoute } from 'astro'; | |
3 | +import { z } from 'zod'; | |
4 | +import { eq, and } from 'drizzle-orm'; | |
5 | +import { getDatabase, schema } from '@/db'; | |
6 | +import { success, unauthorized, serverError, parseBody, notFound } from '@/lib/api'; | |
7 | + | |
8 | +const pollSchema = z.object({ | |
9 | + runnerId: z.string(), | |
10 | + secret: z.string(), | |
11 | +}); | |
12 | + | |
13 | +export const POST: APIRoute = async ({ request }) => { | |
14 | + try { | |
15 | + const parsed = await parseBody(request, pollSchema); | |
16 | + if ('error' in parsed) return parsed.error; | |
17 | + | |
18 | + const { runnerId, secret } = parsed.data; | |
19 | + const db = getDatabase(); | |
20 | + | |
21 | + // Authenticate Runner | |
22 | + const runner = await db.query.pipelineRunners.findFirst({ | |
23 | + where: eq(schema.pipelineRunners.id, runnerId) | |
24 | + }); | |
25 | + | |
26 | + if (!runner || runner.token !== secret) { | |
27 | + return unauthorized(); | |
28 | + } | |
29 | + | |
30 | + // Update Last Seen | |
31 | + await db.update(schema.pipelineRunners) | |
32 | + .set({ status: 'online', lastSeenAt: new Date().toISOString() }) | |
33 | + .where(eq(schema.pipelineRunners.id, runnerId)); | |
34 | + | |
35 | + // Find Queued Jobs for this Repository | |
36 | + // 1. Find runs for repo | |
37 | + // 2. Find queued jobs in those runs | |
38 | + // For specific runner assignment, we would check runnerId on the job, but MVP picks any queued job for the repo. | |
39 | + | |
40 | + // Note: This is an inefficient query logic, ideally we join. | |
41 | + // Simplifying: Get all jobs for this repo? | |
42 | + // We need to join workflow_jobs -> workflow_runs -> repositories. | |
43 | + // Or, since runner is tied to a repo, we look for jobs in runs of that repo. | |
44 | + | |
45 | + // Drizzle query using the relations would be best, but manual lookups are easier to reason about right now without complex joins in one go if not confident with Drizzle syntax. | |
46 | + | |
47 | + // Get active runs for repo | |
48 | + const activeRuns = await db.query.workflowRuns.findMany({ | |
49 | + where: and( | |
50 | + eq(schema.workflowRuns.repositoryId, runner.repositoryId!), | |
51 | + eq(schema.workflowRuns.status, 'queued') // or in_progress but having queued jobs | |
52 | + ), | |
53 | + with: { | |
54 | + jobs: { | |
55 | + where: eq(schema.workflowJobs.status, 'queued'), | |
56 | + limit: 1 | |
57 | + } | |
58 | + }, | |
59 | + limit: 5 // check first 5 active runs | |
60 | + }); | |
61 | + | |
62 | + for (const run of activeRuns) { | |
63 | + if (run.jobs.length > 0) { | |
64 | + const job = run.jobs[0]; | |
65 | + | |
66 | + // Claim Job | |
67 | + await db.update(schema.workflowJobs) | |
68 | + .set({ | |
69 | + status: 'in_progress', | |
70 | + runnerId: runner.id, | |
71 | + startedAt: new Date().toISOString() | |
72 | + }) | |
73 | + .where(eq(schema.workflowJobs.id, job.id)); | |
74 | + | |
75 | + await db.update(schema.workflowRuns) | |
76 | + .set({ status: 'in_progress' }) | |
77 | + .where(eq(schema.workflowRuns.id, run.id)); | |
78 | + | |
79 | + // Fetch steps or just send the command | |
80 | + // Assuming simplified job structure where run command is in job definition for MVP? | |
81 | + // In schema, 'run' is in steps. | |
82 | + const steps = await db.query.workflowSteps.findMany({ | |
83 | + where: eq(schema.workflowSteps.jobId, job.id), | |
84 | + orderBy: (steps, { asc }) => [asc(steps.number)] | |
85 | + }); | |
86 | + | |
87 | + // Just send the first 'run' step for simplicity in MVP | |
88 | + const runStep = steps.find(s => s.run); | |
89 | + | |
90 | + return success({ | |
91 | + id: job.id, | |
92 | + name: job.name, | |
93 | + stepName: runStep?.name || 'Execute', | |
94 | + run: runStep?.run || 'echo "No run command found"' | |
95 | + }); | |
96 | + } | |
97 | + } | |
98 | + | |
99 | + // No job found | |
100 | + // Return 404 to indicate no content? Or 204? | |
101 | + // Client treats 404 as "no job". | |
102 | + return notFound("No job"); | |
103 | + | |
104 | + } catch (error) { | |
105 | + console.error('Runner poll error:', error); | |
106 | + return serverError('Failed to poll jobs'); | |
107 | + } | |
108 | +}; | |
0 | 109 | |
▼src/pages/api/actions/runners/register.ts
+91-0
| @@ -0,0 +1,91 @@ | ||
1 | + | |
2 | +import type { APIRoute } from 'astro'; | |
3 | +import { z } from 'zod'; | |
4 | +import { eq, and } from 'drizzle-orm'; | |
5 | +import { getDatabase, schema } from '@/db'; | |
6 | +import { success, badRequest, unauthorized, serverError, parseBody } from '@/lib/api'; | |
7 | +import crypto from 'node:crypto'; | |
8 | + | |
9 | +const registerSchema = z.object({ | |
10 | + token: z.string(), | |
11 | + name: z.string(), | |
12 | + os: z.string().optional(), | |
13 | + arch: z.string().optional(), | |
14 | + version: z.string().optional(), | |
15 | +}); | |
16 | + | |
17 | +export const POST: APIRoute = async ({ request }) => { | |
18 | + try { | |
19 | + const parsed = await parseBody(request, registerSchema); | |
20 | + if ('error' in parsed) return parsed.error; | |
21 | + | |
22 | + const { token, name, os, arch, version } = parsed.data; | |
23 | + | |
24 | + // Token format: owner/repo:secret | |
25 | + const parts = token.split(':'); | |
26 | + if (parts.length !== 2) { | |
27 | + return unauthorized("Invalid token format"); | |
28 | + } | |
29 | + const [repoSlug, secret] = parts; | |
30 | + const [ownerName, repoName] = repoSlug.split('/'); | |
31 | + | |
32 | + if (!ownerName || !repoName) { | |
33 | + return unauthorized("Invalid token format"); | |
34 | + } | |
35 | + | |
36 | + const db = getDatabase(); | |
37 | + | |
38 | + // Find Repo | |
39 | + const user = await db.query.users.findFirst({ | |
40 | + where: eq(schema.users.username, ownerName) | |
41 | + }); | |
42 | + if (!user) return unauthorized("Invalid owner"); | |
43 | + | |
44 | + const repo = await db.query.repositories.findFirst({ | |
45 | + where: and( | |
46 | + eq(schema.repositories.ownerId, user.id), | |
47 | + eq(schema.repositories.name, repoName) | |
48 | + ) | |
49 | + }); | |
50 | + if (!repo) return unauthorized("Invalid repository"); | |
51 | + | |
52 | + // Verify Secret | |
53 | + const savedSecret = await db.query.workflowSecrets.findFirst({ | |
54 | + where: and( | |
55 | + eq(schema.workflowSecrets.repositoryId, repo.id), | |
56 | + eq(schema.workflowSecrets.name, "ACTIONS_RUNNER_TOKEN") | |
57 | + ) | |
58 | + }); | |
59 | + | |
60 | + if (!savedSecret || savedSecret.encryptedValue !== secret) { | |
61 | + return unauthorized("Invalid registration token"); | |
62 | + } | |
63 | + | |
64 | + // Register Runner | |
65 | + const runnerId = crypto.randomUUID(); | |
66 | + const runnerToken = crypto.randomUUID(); // Private token for this runner | |
67 | + | |
68 | + await db.insert(schema.pipelineRunners).values({ | |
69 | + id: runnerId, | |
70 | + repositoryId: repo.id, | |
71 | + token: runnerToken, | |
72 | + name, | |
73 | + os, | |
74 | + arch, | |
75 | + version, | |
76 | + status: 'online', | |
77 | + lastSeenAt: new Date().toISOString() | |
78 | + }); | |
79 | + | |
80 | + return success({ | |
81 | + id: runnerId, | |
82 | + token: runnerToken, // Runner should save this | |
83 | + name, | |
84 | + secret: runnerToken // Legacy field | |
85 | + }); | |
86 | + | |
87 | + } catch (error) { | |
88 | + console.error('Runner register error:', error); | |
89 | + return serverError('Failed to register runner'); | |
90 | + } | |
91 | +}; | |
0 | 92 | |
▼src/pages/api/actions/trigger-test.ts
+79-0
| @@ -0,0 +1,79 @@ | ||
1 | + | |
2 | +import type { APIRoute } from 'astro'; | |
3 | +import { z } from 'zod'; | |
4 | +import { eq, and } from 'drizzle-orm'; | |
5 | +import { getDatabase, schema } from '@/db'; | |
6 | +import { success, unauthorized, serverError, parseBody, notFound } from '@/lib/api'; | |
7 | +import { getUserFromRequest } from '@/lib/auth'; | |
8 | +import crypto from 'node:crypto'; | |
9 | + | |
10 | +const triggerSchema = z.object({ | |
11 | + repositoryId: z.string() | |
12 | +}); | |
13 | + | |
14 | +export const POST: APIRoute = async ({ request }) => { | |
15 | + try { | |
16 | + const tokenPayload = await getUserFromRequest(request); | |
17 | + if (!tokenPayload) return unauthorized(); | |
18 | + | |
19 | + const parsed = await parseBody(request, triggerSchema); | |
20 | + if ('error' in parsed) return parsed.error; | |
21 | + | |
22 | + const { repositoryId } = parsed.data; | |
23 | + const db = getDatabase(); | |
24 | + | |
25 | + // Verify access | |
26 | + // ... (omitted for brevity, trusting UI check for MVP) | |
27 | + | |
28 | + // Create Workflow Record (if not exists) | |
29 | + // Usually scanned from file, but we mock it. | |
30 | + let workflowId = crypto.randomUUID(); | |
31 | + | |
32 | + await db.insert(schema.workflows).values({ | |
33 | + id: workflowId, | |
34 | + repositoryId, | |
35 | + name: "Test Workflow", | |
36 | + path: ".github/workflows/test.yml", | |
37 | + state: "active" | |
38 | + }); | |
39 | + | |
40 | + // Create Run | |
41 | + const runId = crypto.randomUUID(); | |
42 | + await db.insert(schema.workflowRuns).values({ | |
43 | + id: runId, | |
44 | + workflowId, | |
45 | + repositoryId, | |
46 | + runNumber: 1, | |
47 | + name: "Manual Test Run", | |
48 | + status: "queued", | |
49 | + event: "workflow_dispatch", | |
50 | + headSha: "HEAD", // Placeholder | |
51 | + triggeredById: tokenPayload.userId | |
52 | + }); | |
53 | + | |
54 | + // Create Job | |
55 | + const jobId = crypto.randomUUID(); | |
56 | + await db.insert(schema.workflowJobs).values({ | |
57 | + id: jobId, | |
58 | + runId, | |
59 | + name: "test-job", | |
60 | + status: "queued" | |
61 | + }); | |
62 | + | |
63 | + // Create Step | |
64 | + await db.insert(schema.workflowSteps).values({ | |
65 | + id: crypto.randomUUID(), | |
66 | + jobId, | |
67 | + number: 1, | |
68 | + name: "Run Echo", | |
69 | + run: "echo 'Hello from OpenCodeHub Self-Hosted Runner!' && uptime", | |
70 | + status: "queued" | |
71 | + }); | |
72 | + | |
73 | + return success({ message: "Test job queued", jobId }); | |
74 | + | |
75 | + } catch (error) { | |
76 | + console.error('Trigger test error:', error); | |
77 | + return serverError('Failed to trigger test job'); | |
78 | + } | |
79 | +}; | |
0 | 80 | |