diff --git a/.gitignore b/.gitignore index 62949eb..9abb79e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,13 @@ go.work.sum # env file .env +# Build artifacts +build/ + +# Database +*.db + +# Config (contains secrets) +config.yaml + docs/superpowers/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e2e4fcd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc3658b --- /dev/null +++ b/Makefile @@ -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 ./... diff --git a/README.md b/README.md index 1039544..fdaa603 100644 --- a/README.md +++ b/README.md @@ -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) +``` diff --git a/cmd/mock-gitea/main.go b/cmd/mock-gitea/main.go new file mode 100644 index 0000000..e47015a --- /dev/null +++ b/cmd/mock-gitea/main.go @@ -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)) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c2da756 --- /dev/null +++ b/cmd/server/main.go @@ -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()) +} diff --git a/deploy/helm/proto-gen/Chart.yaml b/deploy/helm/proto-gen/Chart.yaml new file mode 100644 index 0000000..8bd6f84 --- /dev/null +++ b/deploy/helm/proto-gen/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: proto-gen +description: Protobuf code generation platform +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/helm/proto-gen/templates/configmap.yaml b/deploy/helm/proto-gen/templates/configmap.yaml new file mode 100644 index 0000000..717bc9f --- /dev/null +++ b/deploy/helm/proto-gen/templates/configmap.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config +data: + config.yaml: | + 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 }}" + plugins: + {{- range .Values.plugins }} + - language: {{ .language }} + image: "{{ .image }}" + {{- end }} diff --git a/deploy/helm/proto-gen/templates/deployment.yaml b/deploy/helm/proto-gen/templates/deployment.yaml new file mode 100644 index 0000000..a1a7d56 --- /dev/null +++ b/deploy/helm/proto-gen/templates/deployment.yaml @@ -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 }} diff --git a/deploy/helm/proto-gen/templates/ingress.yaml b/deploy/helm/proto-gen/templates/ingress.yaml new file mode 100644 index 0000000..9086514 --- /dev/null +++ b/deploy/helm/proto-gen/templates/ingress.yaml @@ -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 }} diff --git a/deploy/helm/proto-gen/templates/pvc.yaml b/deploy/helm/proto-gen/templates/pvc.yaml new file mode 100644 index 0000000..dbbae83 --- /dev/null +++ b/deploy/helm/proto-gen/templates/pvc.yaml @@ -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 }} diff --git a/deploy/helm/proto-gen/templates/service.yaml b/deploy/helm/proto-gen/templates/service.yaml new file mode 100644 index 0000000..071b3b1 --- /dev/null +++ b/deploy/helm/proto-gen/templates/service.yaml @@ -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 }} diff --git a/deploy/helm/proto-gen/values.yaml b/deploy/helm/proto-gen/values.yaml new file mode 100644 index 0000000..2a326a9 --- /dev/null +++ b/deploy/helm/proto-gen/values.yaml @@ -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: {} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fe55624 --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module proto-gen + +go 1.26.5 + +require ( + github.com/go-chi/chi/v5 v5.3.1 + github.com/google/uuid v1.6.0 + gopkg.in/yaml.v3 v3.0.1 + 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 + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3ab31da --- /dev/null +++ b/go.sum @@ -0,0 +1,163 @@ +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= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..845acf6 --- /dev/null +++ b/internal/api/handlers.go @@ -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) +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..0aea539 --- /dev/null +++ b/internal/api/handlers_test.go @@ -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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..b91abe1 --- /dev/null +++ b/internal/api/server.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..33ee319 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,119 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + Git GitConfig `yaml:"git"` + Cluster ClusterConfig `yaml:"cluster"` + ToolchainRegistry string `yaml:"toolchain_registry"` + TargetRepoTemplate string `yaml:"target_repo_template"` + Plugins []PluginConfig `yaml:"plugins"` +} + +type ServerConfig struct { + Addr string `yaml:"addr"` + PublicURL string `yaml:"public_url"` +} + +type GitConfig struct { + Type string `yaml:"type"` + BaseURL string `yaml:"base_url"` + Token string `yaml:"token"` +} + +type ClusterConfig struct { + Kubeconfig string `yaml:"kubeconfig"` + Namespace string `yaml:"namespace"` + JobTimeout int `yaml:"job_timeout"` + MaxConcurrent int `yaml:"max_concurrent"` +} + +type PluginConfig struct { + Language string `yaml:"language"` + Image string `yaml:"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 := yaml.Unmarshal([]byte(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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fd7bf94 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,127 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_MissingFile(t *testing.T) { + _, err := Load("/nonexistent/config.yaml") + 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.yaml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("writing temp config: %v", err) + } + return path +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..f43e1c9 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/tasks.go b/internal/database/tasks.go new file mode 100644 index 0000000..917eea3 --- /dev/null +++ b/internal/database/tasks.go @@ -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 +} diff --git a/internal/database/tasks_test.go b/internal/database/tasks_test.go new file mode 100644 index 0000000..cd59fdb --- /dev/null +++ b/internal/database/tasks_test.go @@ -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)) + } +} diff --git a/internal/gitclient/gitea.go b/internal/gitclient/gitea.go new file mode 100644 index 0000000..89c0253 --- /dev/null +++ b/internal/gitclient/gitea.go @@ -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 +} diff --git a/internal/gitclient/gitea_test.go b/internal/gitclient/gitea_test.go new file mode 100644 index 0000000..ed795b7 --- /dev/null +++ b/internal/gitclient/gitea_test.go @@ -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") + } +} diff --git a/internal/gitclient/interface.go b/internal/gitclient/interface.go new file mode 100644 index 0000000..ec9dc02 --- /dev/null +++ b/internal/gitclient/interface.go @@ -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) +} diff --git a/internal/k8s/client.go b/internal/k8s/client.go new file mode 100644 index 0000000..5842a03 --- /dev/null +++ b/internal/k8s/client.go @@ -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 +} diff --git a/internal/k8s/client_test.go b/internal/k8s/client_test.go new file mode 100644 index 0000000..b795e68 --- /dev/null +++ b/internal/k8s/client_test.go @@ -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") + } +} diff --git a/internal/k8s/interface.go b/internal/k8s/interface.go new file mode 100644 index 0000000..985cbab --- /dev/null +++ b/internal/k8s/interface.go @@ -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 +} diff --git a/internal/k8s/noop.go b/internal/k8s/noop.go new file mode 100644 index 0000000..eec966b --- /dev/null +++ b/internal/k8s/noop.go @@ -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 +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..e273a7d --- /dev/null +++ b/internal/models/models.go @@ -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"` +} diff --git a/internal/static/app.js b/internal/static/app.js new file mode 100644 index 0000000..2a44010 --- /dev/null +++ b/internal/static/app.js @@ -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 `${status}`; +} + +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 => ` + + ${t.id.substring(0, 8)} + ${t.language} + ${t.proto_repo} + ${t.target_repo} + ${statusBadge(t.status)} + ${formatDate(t.created_at)} + + ${t.status === 'failed' ? `` : ''} + + + `).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 => ` +
+

${p.language}

+

Image: ${p.image}

+
+ `).join(''); + } catch (err) { + console.error('Failed to load plugins:', err); + } +} diff --git a/internal/static/index.html b/internal/static/index.html new file mode 100644 index 0000000..1a80e93 --- /dev/null +++ b/internal/static/index.html @@ -0,0 +1,56 @@ + + + + + + Proto-Gen Platform + + + + + +
+

Generate Code

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Leave empty to auto-generate: {proto_repo}-gen-{lang} +
+ + +
+ + +
+ + + + + diff --git a/internal/static/settings.html b/internal/static/settings.html new file mode 100644 index 0000000..6221977 --- /dev/null +++ b/internal/static/settings.html @@ -0,0 +1,30 @@ + + + + + + Settings - Proto-Gen Platform + + + + + +
+

Settings

+

Toolchain Versions

+
+
+ + + + + diff --git a/internal/static/style.css b/internal/static/style.css new file mode 100644 index 0000000..f21ccec --- /dev/null +++ b/internal/static/style.css @@ -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; +} diff --git a/internal/static/tasks.html b/internal/static/tasks.html new file mode 100644 index 0000000..34722b4 --- /dev/null +++ b/internal/static/tasks.html @@ -0,0 +1,56 @@ + + + + + + Tasks - Proto-Gen Platform + + + + + +
+

Tasks

+
+ + +
+ + + + + + + + + + + + + + + +
IDLanguageProto RepoTarget RepoStatusCreatedActions
+ + +
+ + + + + diff --git a/internal/worker/scheduler.go b/internal/worker/scheduler.go new file mode 100644 index 0000000..c3e7730 --- /dev/null +++ b/internal/worker/scheduler.go @@ -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) + } + } +} diff --git a/internal/worker/scheduler_test.go b/internal/worker/scheduler_test.go new file mode 100644 index 0000000..da45106 --- /dev/null +++ b/internal/worker/scheduler_test.go @@ -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() +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 0000000..b4754e3 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -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) + } +} diff --git a/toolchains/go/Dockerfile b/toolchains/go/Dockerfile new file mode 100644 index 0000000..ac4f2ba --- /dev/null +++ b/toolchains/go/Dockerfile @@ -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"] diff --git a/toolchains/go/entrypoint.sh b/toolchains/go/entrypoint.sh new file mode 100644 index 0000000..233e9e3 --- /dev/null +++ b/toolchains/go/entrypoint.sh @@ -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 ===" diff --git a/toolchains/nodejs/Dockerfile b/toolchains/nodejs/Dockerfile new file mode 100644 index 0000000..f18ea21 --- /dev/null +++ b/toolchains/nodejs/Dockerfile @@ -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"] diff --git a/toolchains/nodejs/entrypoint.sh b/toolchains/nodejs/entrypoint.sh new file mode 100644 index 0000000..3111b60 --- /dev/null +++ b/toolchains/nodejs/entrypoint.sh @@ -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 ==="