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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user