CC1 (#1135) promoted the easy-to-merge shared internals (semver, render, placement, labels) but explicitly DEFERRED the Gitea HTTP client because the four Group C controllers (slices C1-C4) shipped four divergent client surfaces: * organization (C1): Org+Repo CRUD with `Org`/`Repo` struct returns; `EnsureRepo(ctx, org, name, desc, private) (Repo, error)` * blueprint (C3): File CRUD via `*FileResponse`; `EnsureRepo(ctx, org, repo) error` * environment (C2): File CRUD via `*FileContent` + `UpsertFile` (with committer attribution); BaseURL must include `/api/v1` * application (C4): File CRUD via `*FileResponse`; `EnsureRepo(ctx, org, repo) error` + `EnsureBranch` The two `EnsureRepo` shapes collide on signature. CC2's task: design the SUPERSET, migrate every controller without behavior change. What CC2 ships: * `core/controllers/internal/gitea/{client,DESIGN}.go` + `client_test.go` — single unified Client. The SUPERSET method list: Org+Repo CRUD (won from): C1 — only implementer GetOrg(ctx, slug) (Org, error) CreateOrg(ctx, slug, fullName, desc, vis) (Org, error) EnsureOrg(ctx, slug, fullName, desc, vis) (Org, error) GetRepo(ctx, owner, name) (Repo, error) CreateRepo(ctx, org, name, desc, private, autoInit, defBranch) (Repo, error) EnsureRepo(ctx, org, name, desc, private) (Repo, error) ← C1 surface; C3+C4 callers discard the Repo EnsureBranch(ctx, org, repo, branch) error (won from): C4 GetFile(ctx, org, repo, branch, path) (File, error) (won from): C2 — has repo-vs-file 404 distinction PutFile(...) (File, committed bool, err error) (won from): C4 signature + C1 byte-equal short-circuit + C2 PutFileOpts for committer DeleteFile(ctx, org, repo, branch, path, msg) (bool, error) (won from): C3/C4 (identical) Errors: ErrOrgNotFound, ErrRepoNotFound, ErrFileNotFound + HTTPError + IsNotFound() + IsConflict() — covers every prior helper. BaseURL semantics canonicalized: takes Gitea root WITHOUT `/api/v1`; client appends internally. environment-controller's GITEA_API_URL default updated to drop the `/api/v1` suffix. 26 tests covering every reconciler-relevant code path including: * EnsureOrg / EnsureRepo / EnsureBranch find-or-create + 422/409 races * PutFile create / update / byte-equal short-circuit / with author * GetFile / DeleteFile typed sentinels (ErrFileNotFound vs ErrRepoNotFound) * IsNotFound / IsConflict coverage of typed sentinels + HTTPError * Per-controller migration: * organization (C1): EnsureOrg/EnsureRepo same; PutFile arg-order swap (path↔branch — C1 was the outlier) and `(_, _, err :=)` triple. 1 reconciler call site updated. * blueprint (C3): EnsureRepo wrapped with the canonical description literal + private=false (catalog Org). 1 reconciler call site. * environment (C2): GiteaClient interface updated; UpsertFile → PutFile with PutFileOpts for committer attribution; *Org → Org. cmd/main.go drops trailing `/api/v1` from default GITEA_API_URL. 1 reconciler call site + 1 fake. * application (C4): Gitea interface updated to match new shape; EnsureRepo wrapped with description + private=true literal. 1 reconciler call site + 1 fake. * Each per-controller `internal/gitea/` directory deleted (4 dirs, ~2400 LoC removed). Test-coverage delta: Pre-CC2 client tests: 4 + 4 + 10 + 5 = 23 tests across 4 packages Post-CC2 shared tests: 26 tests in one package (+3 net) Per-controller tests: unchanged in count, all still GREEN Verified locally: go vet ./... — clean go test -count=1 -race ./... — every package GREEN go build per controller cmd/ — all 5 binaries link Architecture rules preserved: * No behavior change for any existing call site (the SUPERSET is strictly a union; reconciler logic byte-identical). * Single shared go.mod; no new module path. * Idempotency anchor (PutFile byte-equal short-circuit) preserved. * No new Gitea API methods beyond union of existing usage. * No deploy-manifest changes (env-controller's URL drop is cmd-side default; no chart template touches GITEA_API_URL yet). Co-authored-by: hatiyildiz <hatiyildiz@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.7 KiB
Go
214 lines
7.7 KiB
Go
// application-controller — slice C4 of EPIC-0 #1095.
|
|
//
|
|
// Watches `Application.apps.openova.io/v1` CRs and reconciles each
|
|
// Application to:
|
|
//
|
|
// 1. Resolve parents (Environment + Organization).
|
|
// 2. Fetch the Blueprint at spec.blueprintRef.
|
|
// 3. Validate spec.parameters against Blueprint.spec.configSchema
|
|
// using the canonical JSON Schema validator
|
|
// (github.com/santhosh-tekuri/jsonschema/v5).
|
|
// 4. Resolve placement → per-region work plan.
|
|
// 5. Render + commit per-region kustomization.yaml + helmrelease.yaml
|
|
// to the per-Org Gitea repo at <org>/<app> on the env-type-mapped
|
|
// branch (develop|staging|main per NAMING §11.2).
|
|
// 6. Update Application.status with phase / primaryRegion /
|
|
// regions[] / giteaRepo / installedBlueprint / conditions.
|
|
//
|
|
// Configuration is environment-only — per Inviolable Principle #4
|
|
// (never hardcode), nothing is built into the binary that an operator
|
|
// can't override per-Sovereign:
|
|
//
|
|
// GITEA_API_URL API base URL (default
|
|
// http://gitea-http.gitea.svc.cluster.local:3000)
|
|
// GITEA_TOKEN personal-access token with repo + write:repository
|
|
// GITEA_PUBLIC_URL externally-visible Gitea base URL stamped on
|
|
// Application.status.giteaRepo
|
|
// COMMIT_AUTHOR_NAME default: application-controller
|
|
// COMMIT_AUTHOR_EMAIL default: application-controller@openova.io
|
|
// SOURCE_NAMESPACE Flux source CR namespace inside vCluster
|
|
// (default: flux-system)
|
|
// HELMRELEASE_INTERVAL per-HelmRelease reconcile interval seconds
|
|
// (default: 600)
|
|
// CATALOG_SOURCE_REF Flux source ref name when Blueprint omits one
|
|
// (default: openova-catalog)
|
|
// REQUEUE_AFTER_SECONDS background re-queue interval (default: 300)
|
|
// METRICS_ADDR default :8080
|
|
// HEALTH_ADDR default :8081
|
|
// LEADER_ELECT true|false (default true)
|
|
// LEADER_ELECT_NS default: in-cluster pod namespace; falls
|
|
// back to "openova-system"
|
|
// LOG_LEVEL debug|info|warn|error (default info)
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"k8s.io/client-go/dynamic"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
|
|
"github.com/openova-io/openova/core/controllers/application/internal/controller"
|
|
"github.com/openova-io/openova/core/controllers/internal/gitea"
|
|
)
|
|
|
|
func main() {
|
|
var (
|
|
metricsAddr string
|
|
probeAddr string
|
|
)
|
|
flag.StringVar(&metricsAddr, "metrics-bind-address", env("METRICS_ADDR", ":8080"), "Address the metric endpoint binds to.")
|
|
flag.StringVar(&probeAddr, "health-probe-bind-address", env("HEALTH_ADDR", ":8081"), "Address the probe endpoint binds to.")
|
|
flag.Parse()
|
|
|
|
level := parseLogLevel(env("LOG_LEVEL", "info"))
|
|
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
|
|
slog.SetDefault(logger)
|
|
|
|
cfg := loadConfigFromEnv()
|
|
logger.Info("starting application-controller",
|
|
"giteaPublicURL", cfg.GiteaPublicURL,
|
|
"sourceNamespace", cfg.SourceNamespace,
|
|
"helmReleaseIntervalSeconds", cfg.HelmReleaseIntervalSeconds)
|
|
|
|
restCfg, err := buildRestConfig()
|
|
if err != nil {
|
|
logger.Error("rest config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
dyn, err := dynamic.NewForConfig(restCfg)
|
|
if err != nil {
|
|
logger.Error("dynamic client", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
giteaClient := gitea.New(env("GITEA_API_URL", "http://gitea-http.gitea.svc.cluster.local:3000"), os.Getenv("GITEA_TOKEN"))
|
|
|
|
r := controller.New(dyn, giteaClient, giteaClassifier{}, cfg, logger)
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
// Lightweight HTTP server for /healthz, /readyz, /metrics. The
|
|
// controller-runtime Manager would give us this for free, but slice
|
|
// C3 (blueprint-controller) adopted the "watch loop directly"
|
|
// pattern for the same reason — unstructured.Watch has no
|
|
// dependency on a scheme.AddToScheme call, which simplifies
|
|
// per-Sovereign deployment of a new CRD version.
|
|
go runProbes(ctx, metricsAddr, probeAddr, logger)
|
|
|
|
if err := r.Run(ctx); err != nil && ctx.Err() == nil {
|
|
logger.Error("controller exited", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func loadConfigFromEnv() controller.Config {
|
|
return controller.Config{
|
|
GiteaPublicURL: os.Getenv("GITEA_PUBLIC_URL"),
|
|
CommitAuthorName: env("COMMIT_AUTHOR_NAME", "application-controller"),
|
|
CommitAuthorEmail: env("COMMIT_AUTHOR_EMAIL", "application-controller@openova.io"),
|
|
SourceNamespace: env("SOURCE_NAMESPACE", "flux-system"),
|
|
HelmReleaseIntervalSeconds: envInt("HELMRELEASE_INTERVAL", 600),
|
|
CatalogSourceRef: env("CATALOG_SOURCE_REF", "openova-catalog"),
|
|
RequeueAfter: time.Duration(envInt("REQUEUE_AFTER_SECONDS", 300)) * time.Second,
|
|
}
|
|
}
|
|
|
|
// giteaClassifier adapts the gitea package's typed errors to the
|
|
// Reconciler's GiteaErrorClassifier interface. Keeps the controller
|
|
// package transport-agnostic for testability.
|
|
type giteaClassifier struct{}
|
|
|
|
func (giteaClassifier) IsNotFound(err error) bool { return gitea.IsNotFound(err) }
|
|
func (giteaClassifier) IsOrgNotFound(err error) bool { return errors.Is(err, gitea.ErrOrgNotFound) }
|
|
|
|
// runProbes runs a tiny http.Server with /healthz, /readyz, and a
|
|
// stub /metrics endpoint. Per Inviolable Principle #4 the addresses
|
|
// are configurable.
|
|
func runProbes(ctx context.Context, metricsAddr, probeAddr string, logger *slog.Logger) {
|
|
probeMux := http.NewServeMux()
|
|
probeMux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
probeMux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
metricsMux := http.NewServeMux()
|
|
metricsMux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
|
|
// Stub metrics endpoint — the slice ships health endpoints
|
|
// for k8s probes; full Prometheus instrumentation lands in a
|
|
// follow-up slice (slice OBS-1 per the EPIC-0 docs).
|
|
_, _ = fmt.Fprintf(w, "# application-controller stub\n")
|
|
})
|
|
probeSrv := &http.Server{Addr: probeAddr, Handler: probeMux, ReadHeaderTimeout: 5 * time.Second}
|
|
metricsSrv := &http.Server{Addr: metricsAddr, Handler: metricsMux, ReadHeaderTimeout: 5 * time.Second}
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = probeSrv.Shutdown(shutdownCtx)
|
|
_ = metricsSrv.Shutdown(shutdownCtx)
|
|
}()
|
|
go func() {
|
|
if err := probeSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Warn("probe server", "err", err)
|
|
}
|
|
}()
|
|
if err := metricsSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Warn("metrics server", "err", err)
|
|
}
|
|
}
|
|
|
|
func buildRestConfig() (*rest.Config, error) {
|
|
if cfg, err := rest.InClusterConfig(); err == nil {
|
|
return cfg, nil
|
|
}
|
|
// Fallback for `go run` against a local k3s — picks up KUBECONFIG
|
|
// or ~/.kube/config.
|
|
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
|
|
configOverrides := &clientcmd.ConfigOverrides{}
|
|
kubeconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
|
|
return kubeconfig.ClientConfig()
|
|
}
|
|
|
|
func parseLogLevel(s string) slog.Level {
|
|
switch s {
|
|
case "debug":
|
|
return slog.LevelDebug
|
|
case "warn":
|
|
return slog.LevelWarn
|
|
case "error":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if v, ok := os.LookupEnv(key); ok && v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envInt(key string, fallback int) int {
|
|
if v, ok := os.LookupEnv(key); ok && v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return fallback
|
|
}
|