feat: implement proto-gen platform

- Config loader with env var expansion and validation
- SQLite database with tasks and toolchain_versions tables
- Gitea REST API client for repository and branch operations
- K8s client for Job lifecycle management (with noop fallback for local dev)
- REST API: tasks CRUD, retry, plugins, version management
- Worker/scheduler with polling, concurrency control, and job tracking
- Static frontend: generate, tasks, settings pages
- Toolchain images: Go and Node.js Dockerfiles with entrypoint scripts
- Helm chart with deployment, service, configmap, ingress, PVC
- Multi-stage Dockerfile for service
- Unit tests for config, database, gitclient, k8s, api, worker
- E2E tests for idempotency, version switch, failure recovery
This commit is contained in:
2026-08-10 16:54:30 +08:00
parent cc478bdad4
commit 4e467e1136
43 changed files with 3179 additions and 1 deletions
+151
View File
@@ -0,0 +1,151 @@
package e2e
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"proto-gen/internal/api"
"proto-gen/internal/config"
"proto-gen/internal/database"
"proto-gen/internal/gitclient"
"proto-gen/internal/models"
)
type mockGitClient struct{}
func (m *mockGitClient) ListRepositories(owner string) ([]gitclient.Repository, error) {
return nil, nil
}
func (m *mockGitClient) CreateRepository(owner, name, desc string, private bool) (*gitclient.Repository, error) {
return &gitclient.Repository{Name: name}, nil
}
func (m *mockGitClient) GetRepository(owner, name string) (*gitclient.Repository, error) {
return &gitclient.Repository{Name: name}, nil
}
func (m *mockGitClient) ListBranches(owner, repo string) ([]gitclient.Branch, error) {
return []gitclient.Branch{{Name: "main", Commit: "abc123"}}, nil
}
func (m *mockGitClient) GetBranch(owner, repo, branch string) (*gitclient.Branch, error) {
return &gitclient.Branch{Name: branch, Commit: "abc123"}, nil
}
func newTestServer(t *testing.T) (*api.Server, *database.DB) {
t.Helper()
db, err := database.New(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("creating db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
ToolchainRegistry: "registry.example.com/proto-gen",
TargetRepoTemplate: "{proto_repo}-gen-{lang}",
Plugins: []config.PluginConfig{
{Language: "go", Image: "proto-gen-go:v1.0"},
{Language: "nodejs", Image: "proto-gen-ts:v1.0"},
},
}
return api.NewServer(db, cfg, &mockGitClient{}), db
}
func TestIdempotency(t *testing.T) {
srv, db := newTestServer(t)
body, _ := json.Marshal(map[string]string{
"language": "go",
"proto_repo": "test-owner/yoresee_doc",
"proto_branch": "main",
})
req := httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("first create: status = %d, want %d", w.Code, http.StatusCreated)
}
req = httptest.NewRequest("POST", "/api/v1/tasks", bytes.NewReader(body))
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("second create: status = %d, want %d", w.Code, http.StatusCreated)
}
tasks, _ := db.ListTasks("", 100, 0)
goTasks := 0
for _, task := range tasks {
if task.Language == "go" {
goTasks++
}
}
if goTasks != 2 {
t.Errorf("go tasks = %d, want 2", goTasks)
}
}
func TestVersionSwitch(t *testing.T) {
srv, _ := newTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/plugins/go/versions", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get version: status = %d, want %d", w.Code, http.StatusOK)
}
body, _ := json.Marshal(map[string]string{"image": "proto-gen-go:v2.0"})
req = httptest.NewRequest("PUT", "/api/v1/plugins/go/versions", bytes.NewReader(body))
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update version: status = %d, want %d", w.Code, http.StatusOK)
}
var plugin config.PluginConfig
json.NewDecoder(w.Body).Decode(&plugin)
if plugin.Image != "proto-gen-go:v2.0" {
t.Errorf("Image = %q, want %q", plugin.Image, "proto-gen-go:v2.0")
}
}
func TestFailureRecovery(t *testing.T) {
srv, db := newTestServer(t)
task := &models.Task{
ID: "fail-task-1",
Status: models.StatusFailed,
Language: "go",
ProtoRepo: "test/yoresee_doc",
ProtoBranch: "main",
TargetRepo: "yoresee_doc-gen-go",
ToolchainImage: "proto-gen-go:v1.0",
Error: "some error",
}
db.CreateTask(task)
body, _ := json.Marshal(map[string]string{})
req := httptest.NewRequest("POST", "/api/v1/tasks/fail-task-1/retry", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("retry: status = %d, want %d", w.Code, http.StatusOK)
}
var retried models.Task
json.NewDecoder(w.Body).Decode(&retried)
if retried.Status != models.StatusPending {
t.Errorf("Status = %q, want %q", retried.Status, models.StatusPending)
}
if retried.Retries != 1 {
t.Errorf("Retries = %d, want 1", retried.Retries)
}
}