Compare commits

..
2 Commits
Author SHA1 Message Date
XingfenD 321d78a017 refactor: switch config format from YAML to TOML
- Replace gopkg.in/yaml.v3 with github.com/BurntSushi/toml
- Update struct tags from yaml to toml
- Update config tests to use TOML syntax
- Add testdata/config.toml, update Helm configmap template
2026-08-10 17:19:09 +08:00
XingfenD 4e467e1136 feat: implement proto-gen platform
- Config loader with env var expansion and validation
- SQLite database with tasks and toolchain_versions tables
- Gitea REST API client for repository and branch operations
- K8s client for Job lifecycle management (with noop fallback for local dev)
- REST API: tasks CRUD, retry, plugins, version management
- Worker/scheduler with polling, concurrency control, and job tracking
- Static frontend: generate, tasks, settings pages
- Toolchain images: Go and Node.js Dockerfiles with entrypoint scripts
- Helm chart with deployment, service, configmap, ingress, PVC
- Multi-stage Dockerfile for service
- Unit tests for config, database, gitclient, k8s, api, worker
- E2E tests for idempotency, version switch, failure recovery
2026-08-10 16:54:30 +08:00
45 changed files with 3241 additions and 1 deletions
+9
View File
@@ -25,4 +25,13 @@ go.work.sum
# env file # env file
.env .env
# Build artifacts
build/
# Database
*.db
# Config (contains secrets)
config.yaml
docs/superpowers/ docs/superpowers/
+23
View File
@@ -0,0 +1,23 @@
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o proto-gen ./cmd/server
FROM alpine:3.19
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY --from=builder /app/proto-gen .
COPY --from=builder /app/internal/static ./internal/static
EXPOSE 8080
ENTRYPOINT ["./proto-gen"]
CMD ["--config", "/etc/proto-gen/config.yaml"]
+19
View File
@@ -0,0 +1,19 @@
BINARY_NAME=proto-gen
BUILD_DIR=build
.PHONY: all build clean test lint
all: build
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/server
clean:
@rm -rf $(BUILD_DIR)
test:
go test ./... -v
lint:
golangci-lint run ./...
+95 -1
View File
@@ -1,2 +1,96 @@
# yoresee_codegen # Proto-Gen Platform
Protobuf code generation platform that generates code from protobuf definitions and delivers to independent Git repos via K8s Jobs.
## Features
- **Manual trigger**: Select language, branch, and repository to generate
- **Plugin architecture**: Easily add new languages by adding toolchain images
- **K8s Jobs**: One-time pods for code generation with timeout and retry
- **Idempotent**: Same proto + branch + language = same output
- **SQLite**: Zero-ops state storage
## Quick Start
### Prerequisites
- Go 1.21+
- Kubernetes cluster (K3s/K8s)
- Gitea instance
### Build
```bash
make build
```
### Run
```bash
./build/proto-gen --config config.yaml
```
### Configuration
Copy `testdata/config.yaml` and modify:
```yaml
server:
addr: ":8080"
git:
type: gitea
base_url: "https://git.example.com"
token: ${GIT_TOKEN}
cluster:
kubeconfig: /etc/proto-gen/kubeconfig
namespace: proto-gen
job_timeout: 600
max_concurrent: 2
toolchain_registry: "registry.example.com/proto-gen"
```
### Deploy with Helm
```bash
helm install proto-gen ./deploy/helm/proto-gen \
--set git.baseUrl=https://git.example.com \
--set git.tokenSecret=proto-gen-git-token
```
## API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/tasks` | POST | Create task |
| `/api/v1/tasks` | GET | List tasks |
| `/api/v1/tasks/{id}` | GET | Get task |
| `/api/v1/tasks/{id}/retry` | POST | Retry task |
| `/api/v1/plugins` | GET | List plugins |
| `/api/v1/plugins/{lang}/versions` | GET/PUT | Version management |
## Building Toolchain Images
```bash
# Go toolchain
docker build -t registry.example.com/proto-gen/proto-gen-go:v1.34.2 ./toolchains/go
# Node.js toolchain
docker build -t registry.example.com/proto-gen/proto-gen-ts:v0.9.0 ./toolchains/nodejs
```
## Architecture
```
Browser (manual trigger)
│
▼
┌─────────────────── proto-gen (monolith) ───────────────────┐
│ Frontend │ API Server (REST) │ Scheduler (worker) │
└─────────┬───────────────────────────┬──────────────────────┘
│ Gitea API │ K8s API
▼ ▼
Gitea instance K8s Jobs (toolchain images)
```
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
)
func main() {
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Gitea mock: %s %s", r.Method, r.URL.Path)
switch {
case r.URL.Path == "/api/v1/repos/test-owner/yoresee_doc/branches/main":
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "main",
"commit": map[string]string{"id": "abc123def456"},
})
case r.URL.Path == "/api/v1/user/repos":
json.NewEncoder(w).Encode(map[string]interface{}{"name": "new-repo"})
default:
w.WriteHeader(404)
fmt.Fprintf(w, `{"message": "not found"}`)
}
}))
defer gitea.Close()
fmt.Printf("Mock Gitea running at: %s\n", fmt.Sprintf(`export GIT_BASE_URL=%s`, gitea.URL))
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"proto-gen/internal/api"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/gitclient"
"proto-gen/internal/k8s"
"proto-gen/internal/worker"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("loading config: %v", err)
}
db, err := database.New("proto-gen.db")
if err != nil {
log.Fatalf("initializing database: %v", err)
}
defer db.Close()
git := gitclient.NewClient(cfg.Git.BaseURL, cfg.Git.Token)
var k8sClient k8s.K8sClient
if cfg.Cluster.Kubeconfig == "" || cfg.Cluster.Kubeconfig == "/dev/null" {
log.Println("no kubeconfig provided, running in local mode (no K8s jobs)")
k8sClient = k8s.NewNoopClient()
} else {
k8sClient, err = k8s.NewClient(cfg.Cluster.Kubeconfig)
if err != nil {
log.Printf("warning: k8s client init failed: %v, running in local mode", err)
k8sClient = k8s.NewNoopClient()
}
}
scheduler := worker.NewScheduler(db, k8sClient, cfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go scheduler.Start(ctx)
server := api.NewServer(db, cfg, git)
httpServer := &http.Server{
Addr: cfg.Server.Addr,
Handler: server,
}
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
log.Printf("proto-gen server started on %s", cfg.Server.Addr)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
cancel()
httpServer.Shutdown(context.Background())
}
+24
View File
@@ -0,0 +1,24 @@
[server]
addr = ":8080"
[git]
type = "gitea"
base_url = "https://git.example.com"
token = "test-token"
[cluster]
kubeconfig = "/dev/null"
namespace = "proto-gen"
job_timeout = 600
max_concurrent = 2
toolchain_registry = "registry.example.com/proto-gen"
target_repo_template = "{proto_repo}-gen-{lang}"
[[plugins]]
language = "go"
image = "proto-gen-go:v1.34.2"
[[plugins]]
language = "nodejs"
image = "proto-gen-ts:v0.9.0"
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: proto-gen
description: Protobuf code generation platform
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,28 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
config.toml: |
[server]
addr = ":8080"
[git]
type = "gitea"
base_url = "{{ .Values.git.baseUrl }}"
token = "${GIT_TOKEN}"
[cluster]
kubeconfig = "/etc/proto-gen/kubeconfig"
namespace = "{{ .Values.cluster.namespace }}"
job_timeout = {{ .Values.cluster.jobTimeout }}
max_concurrent = {{ .Values.cluster.maxConcurrent }}
toolchain_registry = "{{ .Values.toolchainRegistry }}"
target_repo_template = "{{ .Values.targetRepoTemplate }}"
{{- range .Values.plugins }}
[[plugins]]
language = "{{ .language }}"
image = "{{ .image }}"
{{- end }}
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: proto-gen
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 8080
env:
- name: GIT_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.git.tokenSecret }}
key: token
- name: KUBECONFIG
value: /etc/proto-gen/kubeconfig
volumeMounts:
- name: config
mountPath: /etc/proto-gen/config.yaml
subPath: config.yaml
- name: kubeconfig
mountPath: /etc/proto-gen/kubeconfig
subPath: kubeconfig
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /api/v1/plugins
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/v1/plugins
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: config
configMap:
name: {{ .Release.Name }}-config
- name: kubeconfig
secret:
secretName: {{ .Values.cluster.kubeconfigSecret }}
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Release.Name }}-data
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ $.Release.Name }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-data
labels:
app: {{ .Release.Name }}
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
protocol: TCP
name: http
selector:
app: {{ .Release.Name }}
+54
View File
@@ -0,0 +1,54 @@
replicaCount: 1
image:
repository: registry.example.com/proto-gen
tag: "latest"
pullPolicy: IfNotPresent
git:
baseUrl: "https://git.example.com"
tokenSecret: "proto-gen-git-token"
cluster:
kubeconfigSecret: "proto-gen-kubeconfig"
namespace: proto-gen-jobs
jobTimeout: 600
maxConcurrent: 2
toolchainRegistry: "registry.example.com/proto-gen"
targetRepoTemplate: "{proto_repo}-gen-{lang}"
plugins:
- language: go
image: "proto-gen-go:v1.34.2"
- language: nodejs
image: "proto-gen-ts:v0.9.0"
service:
type: ClusterIP
port: 8080
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: gen.example.com
paths:
- path: /
pathType: Prefix
tls: []
persistence:
enabled: true
storageClass: ""
size: 1Gi
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
+58
View File
@@ -0,0 +1,58 @@
module proto-gen
go 1.26.5
require (
github.com/BurntSushi/toml v1.6.0
github.com/go-chi/chi/v5 v5.3.1
github.com/google/uuid v1.6.0
k8s.io/api v0.36.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
modernc.org/sqlite v1.56.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+165
View File
@@ -0,0 +1,165 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw=
sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"proto-gen/internal/config"
"proto-gen/internal/models"
)
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
type CreateTaskRequest struct {
Language string `json:"language"`
ProtoRepo string `json:"proto_repo"`
ProtoBranch string `json:"proto_branch"`
TargetRepo string `json:"target_repo"`
}
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
var req CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Language == "" || req.ProtoRepo == "" || req.ProtoBranch == "" {
writeError(w, http.StatusBadRequest, "language, proto_repo, and proto_branch are required")
return
}
plugin := s.cfg.GetPluginByLanguage(req.Language)
if plugin == nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported language: %s", req.Language))
return
}
if req.TargetRepo == "" {
req.TargetRepo = strings.ReplaceAll(
strings.ReplaceAll(s.cfg.TargetRepoTemplate, "{proto_repo}", req.ProtoRepo),
"{lang}", req.Language,
)
}
branch, err := s.git.GetBranch(req.ProtoRepo, req.ProtoRepo, req.ProtoBranch)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("branch not found: %v", err))
return
}
hasRunning, err := s.db.HasRunningTask(req.TargetRepo)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if hasRunning {
writeError(w, http.StatusConflict, "target repo already has a running task")
return
}
task := &models.Task{
ID: uuid.New().String(),
Status: models.StatusPending,
Language: req.Language,
ProtoRepo: req.ProtoRepo,
ProtoBranch: req.ProtoBranch,
ProtoCommit: branch.Commit,
TargetRepo: req.TargetRepo,
ToolchainImage: s.cfg.ToolchainRegistry + "/" + plugin.Image,
}
if err := s.db.CreateTask(task); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create task")
return
}
writeJSON(w, http.StatusCreated, task)
}
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
offset := 0
if o := r.URL.Query().Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
tasks, err := s.db.ListTasks(status, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list tasks")
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
task, err := s.db.GetTask(id)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleRetryTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
task, err := s.db.GetTask(id)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
if task.Status != models.StatusFailed {
writeError(w, http.StatusBadRequest, "can only retry failed tasks")
return
}
hasRunning, err := s.db.HasRunningTask(task.TargetRepo)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if hasRunning {
writeError(w, http.StatusConflict, "target repo already has a running task")
return
}
task.Status = models.StatusPending
task.Error = ""
task.Log = ""
task.Retries++
if err := s.db.UpdateTask(task); err != nil {
writeError(w, http.StatusInternalServerError, "failed to retry task")
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleListPlugins(w http.ResponseWriter, r *http.Request) {
var plugins []config.PluginConfig
for _, p := range s.cfg.Plugins {
plugins = append(plugins, p)
}
writeJSON(w, http.StatusOK, plugins)
}
func (s *Server) handleGetPluginVersion(w http.ResponseWriter, r *http.Request) {
lang := chi.URLParam(r, "lang")
plugin := s.cfg.GetPluginByLanguage(lang)
if plugin == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
writeJSON(w, http.StatusOK, plugin)
}
func (s *Server) handleUpdatePluginVersion(w http.ResponseWriter, r *http.Request) {
lang := chi.URLParam(r, "lang")
plugin := s.cfg.GetPluginByLanguage(lang)
if plugin == nil {
writeError(w, http.StatusNotFound, "plugin not found")
return
}
var req struct {
Image string `json:"image"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Image == "" {
writeError(w, http.StatusBadRequest, "image is required")
return
}
plugin.Image = req.Image
writeJSON(w, http.StatusOK, plugin)
}
+127
View File
@@ -0,0 +1,127 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/gitclient"
"proto-gen/internal/models"
)
type mockGitClient struct{}
func (m *mockGitClient) ListRepositories(owner string) ([]gitclient.Repository, error) {
return nil, nil
}
func (m *mockGitClient) CreateRepository(owner, name, desc string, private bool) (*gitclient.Repository, error) {
return &gitclient.Repository{Name: name}, nil
}
func (m *mockGitClient) GetRepository(owner, name string) (*gitclient.Repository, error) {
return &gitclient.Repository{Name: name}, nil
}
func (m *mockGitClient) ListBranches(owner, repo string) ([]gitclient.Branch, error) {
return []gitclient.Branch{{Name: "main", Commit: "abc123"}}, nil
}
func (m *mockGitClient) GetBranch(owner, repo, branch string) (*gitclient.Branch, error) {
return &gitclient.Branch{Name: branch, Commit: "abc123"}, nil
}
func newTestServer(t *testing.T) *Server {
t.Helper()
db, err := database.New(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("creating db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
ToolchainRegistry: "registry.example.com/proto-gen",
TargetRepoTemplate: "{proto_repo}-gen-{lang}",
Plugins: []config.PluginConfig{
{Language: "go", Image: "proto-gen-go:v1.0"},
{Language: "nodejs", Image: "proto-gen-ts:v1.0"},
},
}
return NewServer(db, cfg, &mockGitClient{})
}
func TestHandleCreateTask(t *testing.T) {
srv := newTestServer(t)
body, _ := json.Marshal(CreateTaskRequest{
Language: "go",
ProtoRepo: "test-owner/yoresee_doc",
ProtoBranch: "main",
})
req := httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("status = %d, want %d", w.Code, http.StatusCreated)
}
var task models.Task
json.NewDecoder(w.Body).Decode(&task)
if task.Language != "go" {
t.Errorf("Language = %q, want %q", task.Language, "go")
}
if task.Status != models.StatusPending {
t.Errorf("Status = %q, want %q", task.Status, models.StatusPending)
}
}
func TestHandleCreateTask_UnsupportedLanguage(t *testing.T) {
srv := newTestServer(t)
body, _ := json.Marshal(CreateTaskRequest{
Language: "python",
ProtoRepo: "test-owner/yoresee_doc",
ProtoBranch: "main",
})
req := httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestHandleListPlugins(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/plugins", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
var plugins []config.PluginConfig
json.NewDecoder(w.Body).Decode(&plugins)
if len(plugins) != 2 {
t.Errorf("len(plugins) = %d, want 2", len(plugins))
}
}
func TestHandleGetTask_NotFound(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/tasks/nonexistent", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/gitclient"
)
type Server struct {
router *chi.Mux
db *database.DB
cfg *config.Config
git gitclient.GitClient
}
func NewServer(db *database.DB, cfg *config.Config, git gitclient.GitClient) *Server {
s := &Server{
router: chi.NewRouter(),
db: db,
cfg: cfg,
git: git,
}
s.routes()
return s
}
func (s *Server) routes() {
s.router.Use(middleware.Logger)
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.Compress(5))
s.router.Route("/api/v1", func(r chi.Router) {
r.Post("/tasks", s.handleCreateTask)
r.Get("/tasks", s.handleListTasks)
r.Get("/tasks/{id}", s.handleGetTask)
r.Post("/tasks/{id}/retry", s.handleRetryTask)
r.Get("/plugins", s.handleListPlugins)
r.Get("/plugins/{lang}/versions", s.handleGetPluginVersion)
r.Put("/plugins/{lang}/versions", s.handleUpdatePluginVersion)
})
s.router.Handle("/*", http.FileServer(http.Dir("internal/static")))
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
func (s *Server) Start(addr string) error {
log.Printf("server listening on %s", addr)
return http.ListenAndServe(addr, s.router)
}
+119
View File
@@ -0,0 +1,119 @@
package config
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/BurntSushi/toml"
)
type Config struct {
Server ServerConfig `toml:"server"`
Git GitConfig `toml:"git"`
Cluster ClusterConfig `toml:"cluster"`
ToolchainRegistry string `toml:"toolchain_registry"`
TargetRepoTemplate string `toml:"target_repo_template"`
Plugins []PluginConfig `toml:"plugins"`
}
type ServerConfig struct {
Addr string `toml:"addr"`
PublicURL string `toml:"public_url"`
}
type GitConfig struct {
Type string `toml:"type"`
BaseURL string `toml:"base_url"`
Token string `toml:"token"`
}
type ClusterConfig struct {
Kubeconfig string `toml:"kubeconfig"`
Namespace string `toml:"namespace"`
JobTimeout int `toml:"job_timeout"`
MaxConcurrent int `toml:"max_concurrent"`
}
type PluginConfig struct {
Language string `toml:"language"`
Image string `toml:"image"`
}
var envVarRegex = regexp.MustCompile(`\$\{(\w+)\}`)
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
expanded := expandEnvVars(string(data))
var cfg Config
if _, err := toml.Decode(expanded, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
func expandEnvVars(s string) string {
return envVarRegex.ReplaceAllStringFunc(s, func(match string) string {
varName := match[2 : len(match)-1]
if val, ok := os.LookupEnv(varName); ok {
return val
}
return match
})
}
func (c *Config) validate() error {
if c.Git.Type == "" {
return fmt.Errorf("git.type is required")
}
if c.Git.BaseURL == "" {
return fmt.Errorf("git.base_url is required")
}
if c.Git.Token == "" {
return fmt.Errorf("git.token is required")
}
if c.Cluster.Namespace == "" {
return fmt.Errorf("cluster.namespace is required")
}
if c.Cluster.JobTimeout == 0 {
c.Cluster.JobTimeout = 600
}
if c.Cluster.MaxConcurrent == 0 {
c.Cluster.MaxConcurrent = 2
}
if c.Server.Addr == "" {
c.Server.Addr = ":8080"
}
if c.TargetRepoTemplate == "" {
c.TargetRepoTemplate = "{proto_repo}-gen-{lang}"
}
for i, p := range c.Plugins {
if p.Language == "" {
return fmt.Errorf("plugins[%d].language is required", i)
}
if p.Image == "" {
return fmt.Errorf("plugins[%d].image is required", i)
}
}
return nil
}
func (c *Config) GetPluginByLanguage(lang string) *PluginConfig {
for i := range c.Plugins {
if strings.EqualFold(c.Plugins[i].Language, lang) {
return &c.Plugins[i]
}
}
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoad_MissingFile(t *testing.T) {
_, err := Load("/nonexistent/config.toml")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoad_MissingGitToken(t *testing.T) {
cfg := `[server]
addr = ":8080"
[git]
type = "gitea"
base_url = "https://git.example.com"
[cluster]
namespace = "proto-gen"`
path := writeTempConfig(t, cfg)
os.Setenv("GIT_TOKEN", "")
_, err := Load(path)
if err == nil {
t.Fatal("expected error for missing git.token")
}
}
func TestLoad_ValidConfig(t *testing.T) {
os.Setenv("GIT_TOKEN", "test-token")
defer os.Unsetenv("GIT_TOKEN")
cfg := `[server]
addr = ":9090"
[git]
type = "gitea"
base_url = "https://git.example.com"
token = "${GIT_TOKEN}"
[cluster]
namespace = "proto-gen"
job_timeout = 300
max_concurrent = 3
[[plugins]]
language = "go"
image = "proto-gen-go:v1.0"`
path := writeTempConfig(t, cfg)
got, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Server.Addr != ":9090" {
t.Errorf("Server.Addr = %q, want %q", got.Server.Addr, ":9090")
}
if got.Git.Token != "test-token" {
t.Errorf("Git.Token = %q, want %q", got.Git.Token, "test-token")
}
if got.Cluster.JobTimeout != 300 {
t.Errorf("Cluster.JobTimeout = %d, want 300", got.Cluster.JobTimeout)
}
if len(got.Plugins) != 1 {
t.Fatalf("len(Plugins) = %d, want 1", len(got.Plugins))
}
if got.Plugins[0].Language != "go" {
t.Errorf("Plugins[0].Language = %q, want %q", got.Plugins[0].Language, "go")
}
}
func TestLoad_DefaultValues(t *testing.T) {
os.Setenv("GIT_TOKEN", "test-token")
defer os.Unsetenv("GIT_TOKEN")
cfg := `[git]
type = "gitea"
base_url = "https://git.example.com"
token = "${GIT_TOKEN}"
[cluster]
namespace = "proto-gen"`
path := writeTempConfig(t, cfg)
got, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Server.Addr != ":8080" {
t.Errorf("Server.Addr = %q, want %q", got.Server.Addr, ":8080")
}
if got.Cluster.JobTimeout != 600 {
t.Errorf("Cluster.JobTimeout = %d, want 600", got.Cluster.JobTimeout)
}
if got.Cluster.MaxConcurrent != 2 {
t.Errorf("Cluster.MaxConcurrent = %d, want 2", got.Cluster.MaxConcurrent)
}
if got.TargetRepoTemplate != "{proto_repo}-gen-{lang}" {
t.Errorf("TargetRepoTemplate = %q, want %q", got.TargetRepoTemplate, "{proto_repo}-gen-{lang}")
}
}
func TestGetPluginByLanguage(t *testing.T) {
cfg := &Config{
Plugins: []PluginConfig{
{Language: "go", Image: "go:v1"},
{Language: "nodejs", Image: "ts:v1"},
},
}
if p := cfg.GetPluginByLanguage("go"); p == nil {
t.Error("GetPluginByLanguage(go) = nil")
} else if p.Image != "go:v1" {
t.Errorf("Image = %q, want %q", p.Image, "go:v1")
}
if p := cfg.GetPluginByLanguage("python"); p != nil {
t.Error("GetPluginByLanguage(python) != nil")
}
}
func writeTempConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing temp config: %v", err)
}
return path
}
+70
View File
@@ -0,0 +1,70 @@
package database
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
conn.Close()
return nil, fmt.Errorf("running migrations: %w", err)
}
return db, nil
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
language TEXT NOT NULL,
proto_repo TEXT NOT NULL,
proto_branch TEXT NOT NULL,
proto_commit TEXT NOT NULL DEFAULT '',
target_repo TEXT NOT NULL,
toolchain_image TEXT NOT NULL,
image_digest TEXT NOT NULL DEFAULT '',
result_commit TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
log TEXT NOT NULL DEFAULT '',
retries INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_running_per_repo
ON tasks(target_repo) WHERE status = 'running';
CREATE TABLE IF NOT EXISTS toolchain_versions (
language TEXT PRIMARY KEY,
image_tag TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Conn() *sql.DB {
return db.conn
}
+97
View File
@@ -0,0 +1,97 @@
package database
import (
"database/sql"
"fmt"
"time"
"proto-gen/internal/models"
)
func (db *DB) CreateTask(task *models.Task) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.conn.Exec(`
INSERT INTO tasks (id, status, language, proto_repo, proto_branch, target_repo, toolchain_image, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Status, task.Language, task.ProtoRepo, task.ProtoBranch,
task.TargetRepo, task.ToolchainImage, now, now,
)
return err
}
func (db *DB) GetTask(id string) (*models.Task, error) {
var t models.Task
var createdAt, updatedAt string
err := db.conn.QueryRow(`
SELECT id, status, language, proto_repo, proto_branch, proto_commit, target_repo,
toolchain_image, image_digest, result_commit, error, log, retries, created_at, updated_at
FROM tasks WHERE id = ?`, id,
).Scan(&t.ID, &t.Status, &t.Language, &t.ProtoRepo, &t.ProtoBranch, &t.ProtoCommit,
&t.TargetRepo, &t.ToolchainImage, &t.ImageDigest, &t.ResultCommit,
&t.Error, &t.Log, &t.Retries, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task not found: %s", id)
}
if err != nil {
return nil, err
}
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return &t, nil
}
func (db *DB) UpdateTask(task *models.Task) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.conn.Exec(`
UPDATE tasks SET status=?, proto_commit=?, image_digest=?, result_commit=?,
error=?, log=?, retries=?, updated_at=?
WHERE id=?`,
task.Status, task.ProtoCommit, task.ImageDigest, task.ResultCommit,
task.Error, task.Log, task.Retries, now, task.ID,
)
return err
}
func (db *DB) ListTasks(status string, limit, offset int) ([]*models.Task, error) {
query := `SELECT id, status, language, proto_repo, proto_branch, proto_commit, target_repo,
toolchain_image, image_digest, result_commit, error, log, retries, created_at, updated_at
FROM tasks`
var args []interface{}
if status != "" {
query += ` WHERE status = ?`
args = append(args, status)
}
query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []*models.Task
for rows.Next() {
var t models.Task
var createdAt, updatedAt string
if err := rows.Scan(&t.ID, &t.Status, &t.Language, &t.ProtoRepo, &t.ProtoBranch,
&t.ProtoCommit, &t.TargetRepo, &t.ToolchainImage, &t.ImageDigest,
&t.ResultCommit, &t.Error, &t.Log, &t.Retries, &createdAt, &updatedAt); err != nil {
return nil, err
}
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
tasks = append(tasks, &t)
}
return tasks, nil
}
func (db *DB) GetPendingTasks(limit int) ([]*models.Task, error) {
return db.ListTasks(string(models.StatusPending), limit, 0)
}
func (db *DB) HasRunningTask(targetRepo string) (bool, error) {
var count int
err := db.conn.QueryRow(`SELECT COUNT(*) FROM tasks WHERE target_repo = ? AND status = 'running'`, targetRepo).Scan(&count)
return count > 0, err
}
+124
View File
@@ -0,0 +1,124 @@
package database
import (
"testing"
"proto-gen/internal/models"
)
func newTestDB(t *testing.T) *DB {
t.Helper()
db, err := New(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("creating test db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestNew_CreatesTables(t *testing.T) {
db := newTestDB(t)
var count int
err := db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('tasks', 'toolchain_versions')`).Scan(&count)
if err != nil {
t.Fatalf("querying tables: %v", err)
}
if count != 2 {
t.Errorf("expected 2 tables, got %d", count)
}
}
func TestCreateAndGetTask(t *testing.T) {
db := newTestDB(t)
task := &models.Task{
ID: "test-1",
Status: models.StatusPending,
Language: "go",
ProtoRepo: "yoresee_doc",
ProtoBranch: "main",
TargetRepo: "yoresee_doc-gen-go",
ToolchainImage: "proto-gen-go:v1.0",
}
if err := db.CreateTask(task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
got, err := db.GetTask("test-1")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if got.Language != "go" {
t.Errorf("Language = %q, want %q", got.Language, "go")
}
if got.Status != models.StatusPending {
t.Errorf("Status = %q, want %q", got.Status, models.StatusPending)
}
}
func TestHasRunningTask(t *testing.T) {
db := newTestDB(t)
has, err := db.HasRunningTask("yoresee_doc-gen-go")
if err != nil {
t.Fatalf("HasRunningTask: %v", err)
}
if has {
t.Error("expected no running task")
}
task := &models.Task{
ID: "test-2",
Status: models.StatusRunning,
Language: "go",
ProtoRepo: "yoresee_doc",
ProtoBranch: "main",
TargetRepo: "yoresee_doc-gen-go",
ToolchainImage: "proto-gen-go:v1.0",
}
if err := db.CreateTask(task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
has, err = db.HasRunningTask("yoresee_doc-gen-go")
if err != nil {
t.Fatalf("HasRunningTask: %v", err)
}
if !has {
t.Error("expected running task")
}
}
func TestListTasks(t *testing.T) {
db := newTestDB(t)
for i := 0; i < 3; i++ {
status := models.StatusPending
if i == 1 {
status = models.StatusRunning
}
db.CreateTask(&models.Task{
ID: "t-" + string(rune('0'+i)),
Status: status,
Language: "go",
ProtoRepo: "yoresee_doc",
ProtoBranch: "main",
TargetRepo: "repo-" + string(rune('0'+i)),
ToolchainImage: "img:v1",
})
}
tasks, err := db.ListTasks("", 10, 0)
if err != nil {
t.Fatalf("ListTasks: %v", err)
}
if len(tasks) != 3 {
t.Errorf("len(tasks) = %d, want 3", len(tasks))
}
tasks, err = db.ListTasks(string(models.StatusRunning), 10, 0)
if err != nil {
t.Fatalf("ListTasks filtered: %v", err)
}
if len(tasks) != 1 {
t.Errorf("len(running) = %d, want 1", len(tasks))
}
}
+136
View File
@@ -0,0 +1,136 @@
package gitclient
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type giteaClient struct {
baseURL string
token string
httpClient *http.Client
}
func NewClient(baseURL, token string) GitClient {
return &giteaClient{
baseURL: baseURL,
token: token,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *giteaClient) doRequest(method, path string, body interface{}) ([]byte, error) {
u := c.baseURL + "/api/v1" + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshaling body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, u, bodyReader)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("gitea API error (%d): %s", resp.StatusCode, string(data))
}
return data, nil
}
func (c *giteaClient) ListRepositories(owner string) ([]Repository, error) {
data, err := c.doRequest("GET", "/repos/search?limit=50&owner="+url.QueryEscape(owner), nil)
if err != nil {
return nil, err
}
var result struct {
Data []Repository `json:"data"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return result.Data, nil
}
func (c *giteaClient) CreateRepository(owner, name, description string, private bool) (*Repository, error) {
body := map[string]interface{}{
"name": name,
"description": description,
"private": private,
}
data, err := c.doRequest("POST", "/user/repos", body)
if err != nil {
return nil, err
}
var repo Repository
if err := json.Unmarshal(data, &repo); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &repo, nil
}
func (c *giteaClient) GetRepository(owner, name string) (*Repository, error) {
data, err := c.doRequest("GET", "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(name), nil)
if err != nil {
return nil, err
}
var repo Repository
if err := json.Unmarshal(data, &repo); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &repo, nil
}
func (c *giteaClient) ListBranches(owner, repo string) ([]Branch, error) {
data, err := c.doRequest("GET", "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/branches", nil)
if err != nil {
return nil, err
}
var branches []Branch
if err := json.Unmarshal(data, &branches); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return branches, nil
}
func (c *giteaClient) GetBranch(owner, repo, branch string) (*Branch, error) {
data, err := c.doRequest("GET", "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/branches/"+url.PathEscape(branch), nil)
if err != nil {
return nil, err
}
var b Branch
if err := json.Unmarshal(data, &b); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &b, nil
}
+78
View File
@@ -0,0 +1,78 @@
package gitclient
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetBranch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/test-owner/test-repo/branches/main" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "token test-token" {
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
}
json.NewEncoder(w).Encode(Branch{Name: "main", Commit: "abc123"})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
b, err := client.GetBranch("test-owner", "test-repo", "main")
if err != nil {
t.Fatalf("GetBranch: %v", err)
}
if b.Name != "main" {
t.Errorf("Name = %q, want %q", b.Name, "main")
}
if b.Commit != "abc123" {
t.Errorf("Commit = %q, want %q", b.Commit, "abc123")
}
}
func TestListBranches(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]Branch{
{Name: "main", Commit: "abc"},
{Name: "dev", Commit: "def"},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
branches, err := client.ListBranches("owner", "repo")
if err != nil {
t.Fatalf("ListBranches: %v", err)
}
if len(branches) != 2 {
t.Errorf("len(branches) = %d, want 2", len(branches))
}
}
func TestCreateRepository(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("unexpected method: %s", r.Method)
}
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["name"] != "new-repo" {
t.Errorf("name = %v, want new-repo", body["name"])
}
json.NewEncoder(w).Encode(Repository{Name: "new-repo", FullName: "owner/new-repo"})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
repo, err := client.CreateRepository("owner", "new-repo", "test", false)
if err != nil {
t.Fatalf("CreateRepository: %v", err)
}
if repo.Name != "new-repo" {
t.Errorf("Name = %q, want %q", repo.Name, "new-repo")
}
}
+21
View File
@@ -0,0 +1,21 @@
package gitclient
type Repository struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Private bool `json:"private"`
Empty bool `json:"empty"`
}
type Branch struct {
Name string `json:"name"`
Commit string `json:"commit"`
}
type GitClient interface {
ListRepositories(owner string) ([]Repository, error)
CreateRepository(owner, name, description string, private bool) (*Repository, error)
GetRepository(owner, name string) (*Repository, error)
ListBranches(owner, repo string) ([]Branch, error)
GetBranch(owner, repo, branch string) (*Branch, error)
}
+149
View File
@@ -0,0 +1,149 @@
package k8s
import (
"bytes"
"context"
"fmt"
"io"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
type k8sClient struct {
clientset kubernetes.Interface
}
func NewClient(kubeconfig string) (K8sClient, error) {
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
return nil, fmt.Errorf("building config: %w", err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("creating clientset: %w", err)
}
return &k8sClient{clientset: clientset}, nil
}
func NewClientFromInterface(clientset kubernetes.Interface) K8sClient {
return &k8sClient{clientset: clientset}
}
func (c *k8sClient) CreateJob(spec *JobSpec) (*Job, error) {
ctx := context.Background()
envVars := make([]corev1.EnvVar, 0, len(spec.EnvVars))
for k, v := range spec.EnvVars {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
}
backoffLimit := int32(0)
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: spec.Name,
Namespace: spec.Namespace,
},
Spec: batchv1.JobSpec{
BackoffLimit: &backoffLimit,
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "toolchain",
Image: spec.Image,
Env: envVars,
},
},
},
},
},
}
if spec.TimeoutSecond > 0 {
timeout := int64(spec.TimeoutSecond)
job.Spec.ActiveDeadlineSeconds = &timeout
}
result, err := c.clientset.BatchV1().Jobs(spec.Namespace).Create(ctx, job, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("creating job: %w", err)
}
return &Job{
Name: result.Name,
Namespace: result.Namespace,
Status: JobPending,
}, nil
}
func (c *k8sClient) GetJob(name, namespace string) (*Job, error) {
ctx := context.Background()
result, err := c.clientset.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("getting job: %w", err)
}
status := JobPending
if result.Status.Succeeded > 0 {
status = JobSucceeded
} else if result.Status.Failed > 0 {
status = JobFailed
} else if result.Status.Active > 0 {
status = JobRunning
}
job := &Job{
Name: result.Name,
Namespace: result.Namespace,
Status: status,
}
if status == JobFailed {
logs, _ := c.getPodLogs(name, namespace)
job.Logs = logs
}
return job, nil
}
func (c *k8sClient) DeleteJob(name, namespace string) error {
ctx := context.Background()
propagation := metav1.DeletePropagationForeground
return c.clientset.BatchV1().Jobs(namespace).Delete(ctx, name, metav1.DeleteOptions{
PropagationPolicy: &propagation,
})
}
func (c *k8sClient) getPodLogs(jobName, namespace string) (string, error) {
ctx := context.Background()
pods, err := c.clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("job-name=%s", jobName),
})
if err != nil || len(pods.Items) == 0 {
return "", err
}
pod := pods.Items[0]
req := c.clientset.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
return "", err
}
defer stream.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, stream); err != nil {
return "", err
}
return buf.String(), nil
}
+80
View File
@@ -0,0 +1,80 @@
package k8s
import (
"testing"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestCreateJob(t *testing.T) {
clientset := fake.NewSimpleClientset()
client := NewClientFromInterface(clientset)
job, err := client.CreateJob(&JobSpec{
Name: "test-job",
Namespace: "default",
Image: "test-image:v1",
EnvVars: map[string]string{"KEY": "value"},
TimeoutSecond: 300,
})
if err != nil {
t.Fatalf("CreateJob: %v", err)
}
if job.Name != "test-job" {
t.Errorf("Name = %q, want %q", job.Name, "test-job")
}
if job.Status != JobPending {
t.Errorf("Status = %q, want %q", job.Status, JobPending)
}
}
func TestGetJob(t *testing.T) {
clientset := fake.NewSimpleClientset(&batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "done-job", Namespace: "default"},
Status: batchv1.JobStatus{Succeeded: 1},
})
client := NewClientFromInterface(clientset)
job, err := client.GetJob("done-job", "default")
if err != nil {
t.Fatalf("GetJob: %v", err)
}
if job.Status != JobSucceeded {
t.Errorf("Status = %q, want %q", job.Status, JobSucceeded)
}
}
func TestGetJob_Running(t *testing.T) {
clientset := fake.NewSimpleClientset(&batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "run-job", Namespace: "default"},
Status: batchv1.JobStatus{Active: 1},
})
client := NewClientFromInterface(clientset)
job, err := client.GetJob("run-job", "default")
if err != nil {
t.Fatalf("GetJob: %v", err)
}
if job.Status != JobRunning {
t.Errorf("Status = %q, want %q", job.Status, JobRunning)
}
}
func TestDeleteJob(t *testing.T) {
clientset := fake.NewSimpleClientset(&batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "del-job", Namespace: "default"},
})
client := NewClientFromInterface(clientset)
err := client.DeleteJob("del-job", "default")
if err != nil {
t.Fatalf("DeleteJob: %v", err)
}
_, err = clientset.BatchV1().Jobs("default").Get(nil, "del-job", metav1.GetOptions{})
if err == nil {
t.Error("expected error getting deleted job")
}
}
+31
View File
@@ -0,0 +1,31 @@
package k8s
type JobStatus string
const (
JobPending JobStatus = "Pending"
JobRunning JobStatus = "Running"
JobSucceeded JobStatus = "Succeeded"
JobFailed JobStatus = "Failed"
)
type JobSpec struct {
Name string
Namespace string
Image string
EnvVars map[string]string
TimeoutSecond int
}
type Job struct {
Name string
Namespace string
Status JobStatus
Logs string
}
type K8sClient interface {
CreateJob(spec *JobSpec) (*Job, error)
GetJob(name, namespace string) (*Job, error)
DeleteJob(name, namespace string) error
}
+27
View File
@@ -0,0 +1,27 @@
package k8s
type noopClient struct{}
func NewNoopClient() K8sClient {
return &noopClient{}
}
func (c *noopClient) CreateJob(spec *JobSpec) (*Job, error) {
return &Job{
Name: spec.Name,
Namespace: spec.Namespace,
Status: JobPending,
}, nil
}
func (c *noopClient) GetJob(name, namespace string) (*Job, error) {
return &Job{
Name: name,
Namespace: namespace,
Status: JobPending,
}, nil
}
func (c *noopClient) DeleteJob(name, namespace string) error {
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package models
import "time"
type TaskStatus string
const (
StatusPending TaskStatus = "pending"
StatusRunning TaskStatus = "running"
StatusSuccess TaskStatus = "success"
StatusFailed TaskStatus = "failed"
)
type Task struct {
ID string `json:"id"`
Status TaskStatus `json:"status"`
Language string `json:"language"`
ProtoRepo string `json:"proto_repo"`
ProtoBranch string `json:"proto_branch"`
ProtoCommit string `json:"proto_commit,omitempty"`
TargetRepo string `json:"target_repo"`
ToolchainImage string `json:"toolchain_image"`
ImageDigest string `json:"image_digest,omitempty"`
ResultCommit string `json:"result_commit,omitempty"`
Error string `json:"error,omitempty"`
Log string `json:"log,omitempty"`
Retries int `json:"retries"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ToolchainVersion struct {
Language string `json:"language"`
ImageTag string `json:"image_tag"`
Enabled bool `json:"enabled"`
UpdatedAt time.Time `json:"updated_at"`
}
+141
View File
@@ -0,0 +1,141 @@
const API_BASE = '/api/v1';
async function api(method, path, body) {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) options.body = JSON.stringify(body);
const res = await fetch(API_BASE + path, options);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Request failed');
}
return res.json();
}
function showMessage(text, type = 'success') {
const msg = document.getElementById('message');
if (!msg) return;
msg.textContent = text;
msg.className = `message ${type}`;
setTimeout(() => msg.className = 'hidden', 5000);
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
function statusBadge(status) {
const colors = {
pending: '#f39c12',
running: '#3498db',
success: '#27ae60',
failed: '#e74c3c'
};
return `<span class="badge" style="background:${colors[status] || '#999'}">${status}</span>`;
}
async function initGeneratePage() {
try {
const plugins = await api('GET', '/plugins');
const select = document.getElementById('language');
plugins.forEach(p => {
const opt = document.createElement('option');
opt.value = p.language;
opt.textContent = p.language;
select.appendChild(opt);
});
} catch (e) {
console.error('Failed to load plugins:', e);
}
document.getElementById('generateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = 'Generating...';
try {
const task = await api('POST', '/tasks', {
language: document.getElementById('language').value,
proto_repo: document.getElementById('protoRepo').value,
proto_branch: document.getElementById('protoBranch').value,
target_repo: document.getElementById('targetRepo').value || undefined
});
showMessage(`Task created: ${task.id}`, 'success');
e.target.reset();
document.getElementById('protoBranch').value = 'main';
} catch (err) {
showMessage(err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Generate';
}
});
}
async function initTasksPage() {
await refreshTasks();
document.getElementById('statusFilter').addEventListener('change', refreshTasks);
setInterval(refreshTasks, 10000);
}
async function refreshTasks() {
const status = document.getElementById('statusFilter').value;
const params = status ? `?status=${status}` : '';
try {
const tasks = await api('GET', `/tasks${params}`);
const tbody = document.getElementById('tasksBody');
const noTasks = document.getElementById('noTasks');
if (!tasks || tasks.length === 0) {
tbody.innerHTML = '';
noTasks.classList.remove('hidden');
return;
}
noTasks.classList.add('hidden');
tbody.innerHTML = tasks.map(t => `
<tr>
<td><code>${t.id.substring(0, 8)}</code></td>
<td>${t.language}</td>
<td>${t.proto_repo}</td>
<td>${t.target_repo}</td>
<td>${statusBadge(t.status)}</td>
<td>${formatDate(t.created_at)}</td>
<td>
${t.status === 'failed' ? `<button onclick="retryTask('${t.id}')" class="btn-small">Retry</button>` : ''}
</td>
</tr>
`).join('');
} catch (err) {
console.error('Failed to load tasks:', err);
}
}
async function retryTask(id) {
try {
await api('POST', `/tasks/${id}/retry`);
showMessage('Task queued for retry');
refreshTasks();
} catch (err) {
showMessage(err.message, 'error');
}
}
async function initSettingsPage() {
try {
const plugins = await api('GET', '/plugins');
const container = document.getElementById('pluginsList');
container.innerHTML = plugins.map(p => `
<div class="plugin-card">
<h3>${p.language}</h3>
<p>Image: <code>${p.image}</code></p>
</div>
`).join('');
} catch (err) {
console.error('Failed to load plugins:', err);
}
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proto-Gen Platform</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<a href="/" class="logo">Proto-Gen</a>
<div class="nav-links">
<a href="/" class="active">Generate</a>
<a href="/tasks.html">Tasks</a>
<a href="/settings.html">Settings</a>
</div>
</nav>
<main>
<h1>Generate Code</h1>
<form id="generateForm">
<div class="form-group">
<label for="protoRepo">Proto Repository</label>
<input type="text" id="protoRepo" placeholder="e.g., owner/yoresee_doc" required>
</div>
<div class="form-group">
<label for="protoBranch">Branch</label>
<input type="text" id="protoBranch" value="main" required>
</div>
<div class="form-group">
<label for="language">Target Language</label>
<select id="language" required>
<option value="">Select language...</option>
</select>
</div>
<div class="form-group">
<label for="targetRepo">Target Repository</label>
<input type="text" id="targetRepo" placeholder="Auto-generated if empty">
<small>Leave empty to auto-generate: {proto_repo}-gen-{lang}</small>
</div>
<button type="submit" class="btn-primary">Generate</button>
</form>
<div id="message" class="hidden"></div>
</main>
<script src="/app.js"></script>
<script>
initGeneratePage();
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Proto-Gen Platform</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<a href="/" class="logo">Proto-Gen</a>
<div class="nav-links">
<a href="/">Generate</a>
<a href="/tasks.html">Tasks</a>
<a href="/settings.html" class="active">Settings</a>
</div>
</nav>
<main>
<h1>Settings</h1>
<h2>Toolchain Versions</h2>
<div id="pluginsList"></div>
</main>
<script src="/app.js"></script>
<script>
initSettingsPage();
</script>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
nav {
background: #2c3e50;
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 2rem;
}
.logo {
color: #ecf0f1;
font-size: 1.5rem;
font-weight: bold;
text-decoration: none;
}
.nav-links a {
color: #bdc3c7;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
}
.nav-links a:hover,
.nav-links a.active {
color: #ecf0f1;
background: rgba(255,255,255,0.1);
}
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 {
margin-bottom: 1.5rem;
color: #2c3e50;
}
h2 {
margin: 1.5rem 0 1rem;
color: #34495e;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input, select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: #3498db;
}
small {
color: #7f8c8d;
font-size: 0.85rem;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
background: #e74c3c;
color: white;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
color: white;
font-size: 0.85rem;
text-transform: capitalize;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.filters select {
width: auto;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
font-weight: 600;
}
code {
background: #f0f0f0;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.9rem;
}
.message {
padding: 1rem;
margin-top: 1rem;
border-radius: 4px;
}
.message.success {
background: #d4edda;
color: #155724;
}
.message.error {
background: #f8d7da;
color: #721c24;
}
.hidden {
display: none;
}
.plugin-card {
background: white;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.plugin-card h3 {
margin-bottom: 0.5rem;
text-transform: uppercase;
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tasks - Proto-Gen Platform</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<a href="/" class="logo">Proto-Gen</a>
<div class="nav-links">
<a href="/">Generate</a>
<a href="/tasks.html" class="active">Tasks</a>
<a href="/settings.html">Settings</a>
</div>
</nav>
<main>
<h1>Tasks</h1>
<div class="filters">
<select id="statusFilter">
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
<button onclick="refreshTasks()" class="btn-secondary">Refresh</button>
</div>
<table id="tasksTable">
<thead>
<tr>
<th>ID</th>
<th>Language</th>
<th>Proto Repo</th>
<th>Target Repo</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tasksBody">
</tbody>
</table>
<div id="noTasks" class="hidden">No tasks found</div>
</main>
<script src="/app.js"></script>
<script>
initTasksPage();
</script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
package worker
import (
"context"
"fmt"
"log"
"time"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/k8s"
"proto-gen/internal/models"
)
type Scheduler struct {
db *database.DB
k8sClient k8s.K8sClient
cfg *config.Config
pollInterval time.Duration
running map[string]bool
}
func NewScheduler(db *database.DB, k8sClient k8s.K8sClient, cfg *config.Config) *Scheduler {
return &Scheduler{
db: db,
k8sClient: k8sClient,
cfg: cfg,
pollInterval: 5 * time.Second,
running: make(map[string]bool),
}
}
func (s *Scheduler) Start(ctx context.Context) {
log.Println("scheduler started")
ticker := time.NewTicker(s.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("scheduler stopped")
return
case <-ticker.C:
s.processPendingTasks()
s.trackRunningJobs()
}
}
}
func (s *Scheduler) processPendingTasks() {
pendingTasks, err := s.db.GetPendingTasks(10)
if err != nil {
log.Printf("error getting pending tasks: %v", err)
return
}
for _, task := range pendingTasks {
if len(s.running) >= s.cfg.Cluster.MaxConcurrent {
log.Printf("max concurrent reached, skipping task %s", task.ID)
return
}
hasRunning, err := s.db.HasRunningTask(task.TargetRepo)
if err != nil {
log.Printf("error checking running task: %v", err)
continue
}
if hasRunning {
log.Printf("task %s: target repo %s already has running task", task.ID, task.TargetRepo)
continue
}
s.startTask(task)
}
}
func (s *Scheduler) startTask(task *models.Task) {
envVars := map[string]string{
"GIT_BASE_URL": s.cfg.Git.BaseURL,
"GIT_TOKEN": s.cfg.Git.Token,
"PROTO_REPO": task.ProtoRepo,
"PROTO_BRANCH": task.ProtoBranch,
"TARGET_REPO": task.TargetRepo,
"OUT_DIR": "/out",
}
jobName := fmt.Sprintf("proto-gen-%s", task.ID[:8])
job, err := s.k8sClient.CreateJob(&k8s.JobSpec{
Name: jobName,
Namespace: s.cfg.Cluster.Namespace,
Image: task.ToolchainImage,
EnvVars: envVars,
TimeoutSecond: s.cfg.Cluster.JobTimeout,
})
if err != nil {
log.Printf("task %s: failed to create job: %v", task.ID, err)
task.Status = models.StatusFailed
task.Error = fmt.Sprintf("failed to create K8s job: %v", err)
s.db.UpdateTask(task)
return
}
task.Status = models.StatusRunning
if err := s.db.UpdateTask(task); err != nil {
log.Printf("task %s: failed to update status: %v", task.ID, err)
return
}
s.running[task.ID] = true
log.Printf("task %s: job %s created", task.ID, job.Name)
}
func (s *Scheduler) trackRunningJobs() {
for taskID := range s.running {
task, err := s.db.GetTask(taskID)
if err != nil {
delete(s.running, taskID)
continue
}
jobName := fmt.Sprintf("proto-gen-%s", task.ID[:8])
job, err := s.k8sClient.GetJob(jobName, s.cfg.Cluster.Namespace)
if err != nil {
log.Printf("task %s: error getting job: %v", taskID, err)
continue
}
switch job.Status {
case k8s.JobSucceeded:
task.Status = models.StatusSuccess
task.Log = job.Logs
s.db.UpdateTask(task)
delete(s.running, taskID)
log.Printf("task %s: completed successfully", taskID)
case k8s.JobFailed:
task.Status = models.StatusFailed
task.Error = "K8s job failed"
task.Log = job.Logs
s.db.UpdateTask(task)
delete(s.running, taskID)
log.Printf("task %s: failed", taskID)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package worker
import (
"testing"
"time"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/k8s"
)
func newTestScheduler(t *testing.T) (*Scheduler, *database.DB) {
t.Helper()
db, err := database.New(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("creating db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
Cluster: config.ClusterConfig{
Namespace: "test",
JobTimeout: 300,
MaxConcurrent: 2,
},
ToolchainRegistry: "registry.example.com",
}
k8sClient := k8s.NewClientFromInterface(nil)
scheduler := NewScheduler(db, k8sClient, cfg)
scheduler.pollInterval = 100 * time.Millisecond
return scheduler, db
}
func TestNewScheduler(t *testing.T) {
scheduler, db := newTestScheduler(t)
defer db.Close()
if scheduler.db == nil {
t.Error("db is nil")
}
if scheduler.k8sClient == nil {
t.Error("k8sClient is nil")
}
}
func TestProcessPendingTasks_Empty(t *testing.T) {
scheduler, db := newTestScheduler(t)
defer db.Close()
scheduler.processPendingTasks()
}
+151
View File
@@ -0,0 +1,151 @@
package e2e
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"proto-gen/internal/api"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/gitclient"
"proto-gen/internal/models"
)
type mockGitClient struct{}
func (m *mockGitClient) ListRepositories(owner string) ([]gitclient.Repository, error) {
return nil, nil
}
func (m *mockGitClient) CreateRepository(owner, name, desc string, private bool) (*gitclient.Repository, error) {
return &gitclient.Repository{Name: name}, nil
}
func (m *mockGitClient) GetRepository(owner, name string) (*gitclient.Repository, error) {
return &gitclient.Repository{Name: name}, nil
}
func (m *mockGitClient) ListBranches(owner, repo string) ([]gitclient.Branch, error) {
return []gitclient.Branch{{Name: "main", Commit: "abc123"}}, nil
}
func (m *mockGitClient) GetBranch(owner, repo, branch string) (*gitclient.Branch, error) {
return &gitclient.Branch{Name: branch, Commit: "abc123"}, nil
}
func newTestServer(t *testing.T) (*api.Server, *database.DB) {
t.Helper()
db, err := database.New(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("creating db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
ToolchainRegistry: "registry.example.com/proto-gen",
TargetRepoTemplate: "{proto_repo}-gen-{lang}",
Plugins: []config.PluginConfig{
{Language: "go", Image: "proto-gen-go:v1.0"},
{Language: "nodejs", Image: "proto-gen-ts:v1.0"},
},
}
return api.NewServer(db, cfg, &mockGitClient{}), db
}
func TestIdempotency(t *testing.T) {
srv, db := newTestServer(t)
body, _ := json.Marshal(map[string]string{
"language": "go",
"proto_repo": "test-owner/yoresee_doc",
"proto_branch": "main",
})
req := httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("first create: status = %d, want %d", w.Code, http.StatusCreated)
}
req = httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("second create: status = %d, want %d", w.Code, http.StatusCreated)
}
tasks, _ := db.ListTasks("", 100, 0)
goTasks := 0
for _, task := range tasks {
if task.Language == "go" {
goTasks++
}
}
if goTasks != 2 {
t.Errorf("go tasks = %d, want 2", goTasks)
}
}
func TestVersionSwitch(t *testing.T) {
srv, _ := newTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/plugins/go/versions", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get version: status = %d, want %d", w.Code, http.StatusOK)
}
body, _ := json.Marshal(map[string]string{"image": "proto-gen-go:v2.0"})
req = httptest.NewRequest("PUT", "/api/v1/plugins/go/versions", bytes.NewReader(body))
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update version: status = %d, want %d", w.Code, http.StatusOK)
}
var plugin config.PluginConfig
json.NewDecoder(w.Body).Decode(&plugin)
if plugin.Image != "proto-gen-go:v2.0" {
t.Errorf("Image = %q, want %q", plugin.Image, "proto-gen-go:v2.0")
}
}
func TestFailureRecovery(t *testing.T) {
srv, db := newTestServer(t)
task := &models.Task{
ID: "fail-task-1",
Status: models.StatusFailed,
Language: "go",
ProtoRepo: "test/yoresee_doc",
ProtoBranch: "main",
TargetRepo: "yoresee_doc-gen-go",
ToolchainImage: "proto-gen-go:v1.0",
Error: "some error",
}
db.CreateTask(task)
body, _ := json.Marshal(map[string]string{})
req := httptest.NewRequest("POST", "/api/v1/tasks/fail-task-1/retry", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("retry: status = %d, want %d", w.Code, http.StatusOK)
}
var retried models.Task
json.NewDecoder(w.Body).Decode(&retried)
if retried.Status != models.StatusPending {
t.Errorf("Status = %q, want %q", retried.Status, models.StatusPending)
}
if retried.Retries != 1 {
t.Errorf("Retries = %d, want 1", retried.Retries)
}
}
+25
View File
@@ -0,0 +1,25 @@
[server]
addr = ":8080"
public_url = "https://gen.example.com"
[git]
type = "gitea"
base_url = "https://git.example.com"
token = "${GIT_TOKEN}"
[cluster]
kubeconfig = "/etc/proto-gen/kubeconfig"
namespace = "proto-gen"
job_timeout = 600
max_concurrent = 2
toolchain_registry = "registry.example.com/proto-gen"
target_repo_template = "{proto_repo}-gen-{lang}"
[[plugins]]
language = "go"
image = "proto-gen-go:v1.34.2"
[[plugins]]
language = "nodejs"
image = "proto-gen-ts:v0.9.0"
+26
View File
@@ -0,0 +1,26 @@
FROM alpine:3.19
ARG PROTOC_VERSION=25.1
ARG PROTOC_GEN_GO_VERSION=v1.34.2
ARG PROTOC_GEN_GO_GRPC_VERSION=v1.5.0
RUN apk add --no-cache git curl unzip
RUN curl -sLO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip && \
unzip -o protoc-${PROTOC_VERSION}-linux-x86_64.zip -d /usr/local && \
rm protoc-${PROTOC_VERSION}-linux-x86_64.zip
RUN curl -sLO https://go.dev/dl/go1.22.5.linux-amd64.tar.gz && \
tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz && \
rm go1.22.5.linux-amd64.tar.gz
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
ENV GOPATH="/root/go"
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERSION} && \
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@${PROTOC_GEN_GO_GRPC_VERSION}
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
set -e
echo "=== Proto-Gen Go Toolchain ==="
echo "PROTO_REPO: ${PROTO_REPO}"
echo "PROTO_BRANCH: ${PROTO_BRANCH}"
echo "TARGET_REPO: ${TARGET_REPO}"
REPO_DIR=$(mktemp -d)
OUT_DIR=${OUT_DIR:-/out}
mkdir -p "${OUT_DIR}"
echo "--- Cloning proto repository ---"
git clone --branch "${PROTO_BRANCH}" --depth 1 \
"https://${GIT_TOKEN}@${GIT_BASE_URL}/${PROTO_REPO}.git" "${REPO_DIR}/proto"
PROTO_SHA=$(cd "${REPO_DIR}/proto" && git rev-parse HEAD)
echo "Proto commit: ${PROTO_SHA}"
echo "--- Running protoc ---"
PROTO_FILES=$(find "${REPO_DIR}/proto" -name "*.proto" -type f)
for proto_file in ${PROTO_FILES}; do
protoc \
--proto_path="${REPO_DIR}/proto" \
--go_out="${OUT_DIR}" --go_opt=paths=source_relative \
--go-grpc_out="${OUT_DIR}" --go-grpc_opt=paths=source_relative \
"${proto_file#${REPO_DIR}/proto/}"
done
echo "--- Cloning target repository ---"
TARGET_DIR=$(mktemp -d)
if git clone "https://${GIT_TOKEN}@${GIT_BASE_URL}/${TARGET_REPO}.git" "${TARGET_DIR}/target" 2>/dev/null; then
echo "Target repo exists"
else
echo "Creating target repository..."
curl -s -X POST \
-H "Authorization: token ${GIT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\": \"${TARGET_REPO}\", \"auto_init\": false}" \
"${GIT_BASE_URL}/api/v1/user/repos"
git clone "https://${GIT_TOKEN}@${GIT_BASE_URL}/${TARGET_REPO}.git" "${TARGET_DIR}/target"
fi
echo "--- Checking for changes ---"
cd "${TARGET_DIR}/target"
cp -r "${OUT_DIR}"/* . 2>/dev/null || true
git add -A
if git diff --cached --quiet; then
echo "No changes detected, idempotent exit"
exit 0
fi
echo "--- Committing and pushing ---"
git config user.email "proto-gen@bot"
git config user.name "Proto-Gen Bot"
git commit -m "gen(go): from ${PROTO_REPO}@${PROTO_SHA}"
git push origin "${PROTO_BRANCH}"
echo "=== Done ==="
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine
ARG PROTOC_VERSION=25.1
ARG PROTOC_GEN_TS_VERSION=1.8.2
RUN apk add --no-cache git curl unzip
RUN curl -sLO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip && \
unzip -o protoc-${PROTOC_VERSION}-linux-x86_64.zip -d /usr/local && \
rm protoc-${PROTOC_VERSION}-linux-x86_64.zip
RUN npm install -g protoc-gen-ts@${PROTOC_GEN_TS_VERSION}
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
set -e
echo "=== Proto-Gen Node.js Toolchain ==="
echo "PROTO_REPO: ${PROTO_REPO}"
echo "PROTO_BRANCH: ${PROTO_BRANCH}"
echo "TARGET_REPO: ${TARGET_REPO}"
REPO_DIR=$(mktemp -d)
OUT_DIR=${OUT_DIR:-/out}
mkdir -p "${OUT_DIR}"
echo "--- Cloning proto repository ---"
git clone --branch "${PROTO_BRANCH}" --depth 1 \
"https://${GIT_TOKEN}@${GIT_BASE_URL}/${PROTO_REPO}.git" "${REPO_DIR}/proto"
PROTO_SHA=$(cd "${REPO_DIR}/proto" && git rev-parse HEAD)
echo "Proto commit: ${PROTO_SHA}"
echo "--- Running protoc ---"
PROTO_FILES=$(find "${REPO_DIR}/proto" -name "*.proto" -type f)
for proto_file in ${PROTO_FILES}; do
protoc \
--proto_path="${REPO_DIR}/proto" \
--ts_out="${OUT_DIR}" --ts_opt=long_type_string
"${proto_file#${REPO_DIR}/proto/}"
done
echo "--- Cloning target repository ---"
TARGET_DIR=$(mktemp -d)
if git clone "https://${GIT_TOKEN}@${GIT_BASE_URL}/${TARGET_REPO}.git" "${TARGET_DIR}/target" 2>/dev/null; then
echo "Target repo exists"
else
echo "Creating target repository..."
curl -s -X POST \
-H "Authorization: token ${GIT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\": \"${TARGET_REPO}\", \"auto_init\": false}" \
"${GIT_BASE_URL}/api/v1/user/repos"
git clone "https://${GIT_TOKEN}@${GIT_BASE_URL}/${TARGET_REPO}.git" "${TARGET_DIR}/target"
fi
echo "--- Checking for changes ---"
cd "${TARGET_DIR}/target"
cp -r "${OUT_DIR}"/* . 2>/dev/null || true
git add -A
if git diff --cached --quiet; then
echo "No changes detected, idempotent exit"
exit 0
fi
echo "--- Committing and pushing ---"
git config user.email "proto-gen@bot"
git config user.name "Proto-Gen Bot"
git commit -m "gen(nodejs): from ${PROTO_REPO}@${PROTO_SHA}"
git push origin "${PROTO_BRANCH}"
echo "=== Done ==="