feat(paassvc): native in-process PaaS deploy control plane (/v1/paas/*) (#52)

Port the standalone Dokploy platform's observe + deploy halves into the unified
cloud binary as clients/paassvc — the 'one and only one way to deploy' made
native. Follows the clients/ml pattern exactly: a self-contained dynamic k8s
client, cloud.Register'd from init() (order 128), global-admin-gated, fail-closed
when no cluster.

Surface (global-admin only; user-facing view lives in console2):
  GET  /v1/paas/apps             fleet drift board (declared/running/latest/drift+health)
  GET  /v1/paas/apps/:app        one service row by CR name (main->test->dev)
  POST /v1/paas/apps/:app/deploy deploy a tag by merge-patching Service CR .spec.image
  GET  /v1/paas/health           real k8s reachability + Service CRD probe

- drift.go: 1:1 port of apps-drift.ts (computeDrift/isSemverTag + 6 DriftKinds,
  identical severities). Pure, zero IO.
- paas.go: observeFleet lists hanzo.ai/v1 services across hanzo/-testnet/-devnet
  (inventory.ts DEFAULT_TARGETS), joins the live Deployment for the running tag
  (the operator Service CR status does NOT surface the running image — confirmed
  against the live CRD), health/phase/endpoints from the reconciled CR status.
  deploy merge-patches .spec.image (deploy-executor.ts parity) -> operator rolls it.
- Stateless: reads the cluster live (CRs are the source of truth); no apps-table
  copy, no cron readers (dropped vs the Node platform).

Tested green:
- 30+ unit cases incl. the 9 drift-contract cases ported verbatim; go test green,
  gofmt clean, go vet clean, full cloud binary builds (-tags cloud).
- Live-cluster probe (paasintegration tag, PAAS_IT-gated): observeFleet returned
  82 real rows matching kubectl; an idempotent same-image patch on pricing
  round-tripped through the operator with generation unchanged (6->6) = write
  path proven WITHOUT triggering a rollout, zero disturbance to live state.

Design + full port map: universe/docs/architecture/paas-in-cloud.md.
RBAC (cloud-paassvc -> cloud-api SA): universe infra/k8s/cloud/paassvc-rbac.yaml.

Additive: platform.hanzo.ai stays as the internal-admin console; this is the
native backend + (next) the console2 user UI. No forced retirement.
This commit is contained in:
2026-07-01 07:19:32 -07:00
committed by GitHub
parent da80e34781
commit fbb76912eb
5 changed files with 1438 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
// Package paassvc mounts the native, in-process Hanzo PaaS control plane at
// /v1/paas/*: the "one and only one way to deploy" made native to the cloud
// binary. It is the Go port of the standalone Dokploy-based platform's
// build→deploy lifecycle (pkg/platform/src/services/ci/deploy-executor.ts +
// services/apps/inventory.ts + db/schema/apps-drift.ts), collapsed into an
// in-process cloud subsystem exactly like clients/ml is the k8s bridge for the
// Kubeflow CRDs.
//
// The deploy mechanism is the SAME one the operator already reconciles: a
// merge-patch of the operator `Service` CR's `.spec.image`. No second deployer
// is invented; the Hanzo operator owns the rollout. This module only observes
// the declared/running/latest tags per service (the drift board) and flips the
// one CR field a deploy changes — the identical contract the Node deploy-executor
// implemented, now native.
//
// drift.go is the PURE half: it derives the drift verdict for one observed
// service row and performs no IO. It is a faithful port of
// `pkg/platform/src/db/schema/apps-drift.ts` so the two implementations can never
// disagree about what "drift" means (one way to compute drift, period). The
// cluster reader (paas.go) owns observing the tags; this file only interprets
// them.
package paassvc
import "regexp"
// semverTagRE is the semver-only policy from the platform contract's constraint 1:
// every declared/running tag MUST be exactly `vMAJOR.MINOR.PATCH`. Anything else
// (`:main`, `:latest`, `:dev`, `:edge`, `sha-…`, `1.42.33-billing`, …) is a
// floating reference. Mirrors the `^v\d+\.\d+\.\d+$` gate the platform's
// reconciler enforces at the patch boundary (apps-drift.ts `SEMVER_TAG`).
var semverTagRE = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
// IsSemverTag reports whether tag is a strict `vX.Y.Z` semver tag.
func IsSemverTag(tag string) bool { return tag != "" && semverTagRE.MatchString(tag) }
// DriftKind enumerates the kinds of drift from the platform contract
// (apps-drift.ts `DriftKind`). Each value is independent — one service row can
// carry several at once (e.g. a floating running tag with a zero-asset release).
type DriftKind string
const (
// DriftStale — declared ≠ latest: a newer release exists but is not declared. (yellow)
DriftStale DriftKind = "stale"
// DriftUnrolled — running ≠ declared: the cluster has not rolled to the declared tag. (yellow)
DriftUnrolled DriftKind = "un-rolled"
// DriftFloatingDeclared — declaredTag is not strict semver; the reconciler would refuse it. (red)
DriftFloatingDeclared DriftKind = "floating-declared"
// DriftFloatingRunning — runningTag is not strict semver; policy violation on the cluster. (red)
DriftFloatingRunning DriftKind = "floating-running"
// DriftNoRelease — no GH Release found for the declared tag. (red)
DriftNoRelease DriftKind = "no-release"
// DriftZeroAssets — GH Release exists but shipped 0 assets. (red)
DriftZeroAssets DriftKind = "zero-assets"
)
// DriftSeverity is the aggregate drift severity. "ok" = no flags; otherwise the
// max over flags.
type DriftSeverity string
const (
SeverityOK DriftSeverity = "ok"
SeverityYellow DriftSeverity = "yellow"
SeverityRed DriftSeverity = "red"
)
// severityOf maps each kind to its severity. Stale/un-rolled are warnings; the
// rest are hard drift. Mirrors apps-drift.ts `SEVERITY`.
var severityOf = map[DriftKind]DriftSeverity{
DriftStale: SeverityYellow,
DriftUnrolled: SeverityYellow,
DriftFloatingDeclared: SeverityRed,
DriftFloatingRunning: SeverityRed,
DriftNoRelease: SeverityRed,
DriftZeroAssets: SeverityRed,
}
// DriftFlag is a single drift finding: its kind, severity, and a human-readable
// reason (apps-drift.ts `DriftFlag`).
type DriftFlag struct {
Kind DriftKind `json:"kind"`
Severity DriftSeverity `json:"severity"`
Message string `json:"message"`
}
// Drift is the drift verdict for one observed service row: the ordered flags plus
// the rolled-up severity (apps-drift.ts `Drift`).
type Drift struct {
Severity DriftSeverity `json:"severity"`
Flags []DriftFlag `json:"flags"`
}
// Observed is the minimal set of already-observed tag fields the drift derivation
// reads — mirrors the `Pick<App, …>` the TS `computeDrift` accepts. The reader
// (paas.go) fills these from the cluster; the release fields are populated by the
// GH-release reader (a follow-up), so today they are the honest zero value
// (ReleaseURL == "" ⇒ no-release, exactly like the un-populated TS columns).
type Observed struct {
DeclaredTag string // what SHOULD run — spec.image.tag on the operator Service CR
RunningTag string // what ACTUALLY runs — observed from the CR status / Deployment
LatestTag string // newest released tag (GH release reader; empty until wired)
ReleaseURL string // GH Release URL for DeclaredTag (empty ⇒ no-release)
ReleaseAssets int // asset count on the GH Release (0 ⇒ zero-assets)
}
func flag(kind DriftKind, message string) DriftFlag {
return DriftFlag{Kind: kind, Severity: severityOf[kind], Message: message}
}
// ComputeDriftFlags derives the drift flags for one observed service row, exactly
// per the platform contract (apps-drift.ts `computeDriftFlags`).
//
// Detection rules (each independent; a row may trip several):
//
// - floating-declared — DeclaredTag is set but not vX.Y.Z. The reconciler
// refuses non-semver declarations, so this is hard drift. (When the
// declaration itself is floating, comparing it against LatestTag for "stale"
// is meaningless, so stale is suppressed in that case.)
// - floating-running — RunningTag is set but not vX.Y.Z: the cluster is running
// a floating image. Hard drift.
// - stale — DeclaredTag and LatestTag are both known semver and differ: a newer
// release exists that is not yet declared.
// - un-rolled — DeclaredTag and RunningTag are both known and differ: the
// declaration has not reached the cluster yet.
// - no-release — a DeclaredTag exists but no GH Release was found (ReleaseURL "").
// - zero-assets — a GH Release exists (ReleaseURL set) but ReleaseAssets == 0.
//
// Tags are compared verbatim (the reader stores reality un-normalized); no
// ordering is assumed beyond equality — matching the contract.
func ComputeDriftFlags(o Observed) []DriftFlag {
var flags []DriftFlag
declaredFloating := o.DeclaredTag != "" && !IsSemverTag(o.DeclaredTag)
runningFloating := o.RunningTag != "" && !IsSemverTag(o.RunningTag)
if declaredFloating {
flags = append(flags, flag(DriftFloatingDeclared,
"declared tag \""+o.DeclaredTag+"\" is not semver (vX.Y.Z); the reconciler will refuse it"))
}
if runningFloating {
flags = append(flags, flag(DriftFloatingRunning,
"running tag \""+o.RunningTag+"\" is a floating reference, not semver"))
}
// "stale" only makes sense for a semver declaration: declared ≠ latest.
if !declaredFloating && o.DeclaredTag != "" && o.LatestTag != "" && o.DeclaredTag != o.LatestTag {
flags = append(flags, flag(DriftStale,
"declared "+o.DeclaredTag+" is behind latest "+o.LatestTag))
}
// "un-rolled": running ≠ declared (only once running is known and not already
// flagged as floating, to avoid double-counting).
if !runningFloating && o.DeclaredTag != "" && o.RunningTag != "" && o.RunningTag != o.DeclaredTag {
flags = append(flags, flag(DriftUnrolled,
"running "+o.RunningTag+" has not rolled to declared "+o.DeclaredTag))
}
// Release-artifact integrity is keyed off the declared tag.
if o.DeclaredTag != "" {
if o.ReleaseURL == "" {
flags = append(flags, flag(DriftNoRelease,
"no GH Release found for declared tag "+o.DeclaredTag))
} else if o.ReleaseAssets == 0 {
flags = append(flags, flag(DriftZeroAssets,
"GH Release for "+o.DeclaredTag+" shipped 0 assets"))
}
}
return flags
}
// DriftSeverityOf rolls a list of flags up to a single severity (red > yellow >
// ok). Mirrors apps-drift.ts `driftSeverity`.
func DriftSeverityOf(flags []DriftFlag) DriftSeverity {
red, yellow := false, false
for _, f := range flags {
switch f.Severity {
case SeverityRed:
red = true
case SeverityYellow:
yellow = true
}
}
if red {
return SeverityRed
}
if yellow {
return SeverityYellow
}
return SeverityOK
}
// ComputeDrift is the full drift verdict (flags + rolled-up severity) for one
// observed service row (apps-drift.ts `computeDrift`). Flags is always non-nil so
// the JSON encodes `[]`, never `null`.
func ComputeDrift(o Observed) Drift {
flags := ComputeDriftFlags(o)
if flags == nil {
flags = []DriftFlag{}
}
return Drift{Severity: DriftSeverityOf(flags), Flags: flags}
}
+716
View File
@@ -0,0 +1,716 @@
// paas.go — the cluster-facing half of the native Hanzo PaaS control plane.
//
// It mounts /v1/paas/* on the unified cloud binary and speaks to the SAME
// operator surface the standalone platform's deploy-executor drove: the
// `hanzo.ai/v1` `services` CustomResource. Two responsibilities, both a straight
// port of the Node platform:
//
// GET /v1/paas/apps — the fleet drift board (inventory.ts): list every
// operator Service CR across the platform
// namespaces, read declared vs running tag +
// health from the CR (+ its status), and attach
// the drift verdict (drift.go / apps-drift.ts).
// GET /v1/paas/apps/:app — one service row by CR name.
// POST /v1/paas/apps/:app/deploy— deploy a new image tag by merge-patching the
// Service CR's `.spec.image` (deploy-executor.ts).
// The operator reconciles the rollout; cloud never
// reimplements a deployer.
// GET /v1/paas/health — real k8s reachability + Service CRD presence.
//
// SECURITY — every route is GLOBAL-ADMIN ONLY, fail-closed, gated on the SAME
// predicate the rest of cloud uses: c.IsAdmin() (true only for a JWT-validated
// principal whose org is the admin org, matching the gateway's admin-guard — see
// clients/admin). Unlike clients/ml (per-tenant namespaces), the PaaS control
// plane reads and mutates SYSTEM Service CRs across the whole fleet, so it is
// admin-only: a tenant must never patch another org's — or a platform — service.
// The user-facing PaaS view lives in console2; users never call this surface.
//
// k8s client: built in-process from the in-cluster service account
// (rest.InClusterConfig) with a KUBECONFIG fallback for local/dev — the identical
// construction clients/ml uses. When no kubeconfig is resolvable the subsystem
// mounts anyway and every endpoint fails closed (503 + the real init error; the
// health route reports "degraded"), never status-theater.
package paassvc
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/zip"
luxlog "github.com/luxfi/log"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
k8stypes "k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// servicesGVR is the operator Service CR — the single source of truth for the
// declared image of every Hanzo service. Asserted in the tests; a typo here
// silently breaks the whole board.
var servicesGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "services"}
// deploymentsGVR is the live Deployment behind each Service — the source of the
// RUNNING tag (the operator Service CR status does not surface the running image,
// so the running tag is observed from the Deployment's container, exactly as the
// platform inventory reads it in inventory.ts). Read-only for this subsystem.
var deploymentsGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
// nsEnv maps each scanned namespace to the lifecycle env it represents, mirroring
// the platform inventory's DEFAULT_TARGETS (inventory.ts): the `hanzo` namespace
// is production (main); the env-split namespaces map to test/dev. Only listed
// namespaces are scanned — the reader never reaches beyond the platform tier.
// Cross-cluster federation (lux-k8s/zoo-k8s) is a follow-up phase (a per-cluster
// client from a KMS-loaded kubeconfig), exactly as the Node inventory federates.
var nsEnv = map[string]string{
"hanzo": "main",
"hanzo-testnet": "test",
"hanzo-devnet": "dev",
}
// appNameRE constrains the :app path segment to a DNS-1123 label (every Service
// CR metadata.name satisfies this). Validated at the boundary; it is the
// injection guard for the CR name a deploy/read targets.
var appNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// imageRepoRE constrains a deploy's target image repository. A registry path of
// host/namespace/name segments (letters, digits, ., -, _, /). The tag is
// validated separately (deploy accepts any non-empty tag so a controlled hotfix
// to a floating tag is possible, but the drift board then flags it loudly).
var imageRepoRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*[a-z0-9]$`)
const userAgent = "hanzo-cloud-paassvc"
type svc struct {
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
initErr string // why dyn is nil, surfaced by health + ready()
log luxlog.Logger
}
// Mount wires the /v1/paas/* surface onto app. Every handler gates on
// c.IsAdmin() first (global-admin only), then reads/patches the operator Service
// CRs.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("paassvc.Mount: nil zip.App")
}
if deps.Logger == nil {
return fmt.Errorf("paassvc.Mount: nil deps.Logger")
}
log := deps.Logger.New("subsystem", "paas")
s := &svc{log: log}
if dyn, err := newDynamic(); err != nil {
s.initErr = err.Error()
log.Warn("kubernetes client unavailable; /v1/paas endpoints will fail closed", "err", err)
} else {
s.dyn = dyn
}
app.Get("/v1/paas/apps", s.guard(s.listApps))
app.Get("/v1/paas/apps/:app", s.guard(s.getApp))
app.Post("/v1/paas/apps/:app/deploy", s.guard(s.deploy))
app.Get("/v1/paas/health", s.health)
log.Info("paas control plane mounted",
"prefix", "/v1/paas", "k8s", s.dyn != nil, "brand", deps.Brand, "env", deps.Env)
return nil
}
// Registered under "paassvc" (not "paas") for the same reason clients/ml uses
// "mlsvc": serve.go auto-mounts a generic GET /v1/<name>/health BEFORE MountAll
// and zip is first-match-wins, so a name of "paas" would shadow the real-probe
// /v1/paas/health. "paassvc" keeps the generic liveness at the unrouted
// /v1/paassvc/health and lets the real probe own /v1/paas/health. Order 128 binds
// the /v1/paas family before the projectsvc (125) neighbours and well before the
// AI /v1/* catch-all (150); it has no ordering dependency (self-contained k8s
// client).
func init() {
cloud.Register("paassvc", 128, func(app any, deps cloud.Deps) error {
a, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("paassvc.Mount: app is %T, want *zip.App", app)
}
return Mount(a, deps)
})
}
// guard wraps a handler with the global-admin gate. Fail-closed: any request whose
// validated identity is not a global admin is refused 403 before the handler — no
// cluster object is read or mutated, matching clients/admin.guard.
func (s *svc) guard(h zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if !c.IsAdmin() {
return zip.ErrForbidden("global admin required")
}
return h(c)
}
}
// ── observe: the drift board (inventory.ts) ──────────────────────────────────
// AppView is one service row on the drift board: the observed tags + topology +
// the derived drift verdict. It is the Go analogue of the platform's `AppView`
// (apps-api.ts) so console2 renders the same shape the Dokploy board did.
type AppView struct {
ID string `json:"id"` // <org>/<app>/<env>, e.g. hanzoai/iam/main
Org string `json:"org"` // image namespace, e.g. hanzoai
App string `json:"app"` // service / CR name, e.g. iam
Env string `json:"env"` // main|test|dev
Repo string `json:"repo"` // owner/repo, e.g. hanzoai/iam
Registry string `json:"registry"`
DeclaredTag string `json:"declaredTag"`
RunningTag string `json:"runningTag"`
LatestTag string `json:"latestTag"`
Health string `json:"health"` // green|yellow|red|"" (unknown)
Phase string `json:"phase"` // operator status.phase (Running/…)
Cluster string `json:"cluster"`
Namespace string `json:"namespace"`
Endpoints []string `json:"endpoints"`
Drift Drift `json:"drift"`
}
// listApps returns the whole fleet's drift board, ordered deterministically
// (org, app, env). Optional narrowing filters mirror the platform board:
// ?env=, ?health=, ?drift=1 (only rows that are actually drifting), ?org=.
func (s *svc) listApps(c *zip.Ctx) error {
if err := s.ready(); err != nil {
return err
}
views, err := s.observeFleet(c.Context())
if err != nil {
return err
}
env := strings.TrimSpace(c.Query("env"))
health := strings.TrimSpace(c.Query("health"))
org := strings.TrimSpace(c.Query("org"))
driftOnly := c.Query("drift") == "1" || c.Query("drift") == "true"
out := make([]AppView, 0, len(views))
byDrift := map[DriftSeverity]int{SeverityOK: 0, SeverityYellow: 0, SeverityRed: 0}
for _, v := range views {
if env != "" && v.Env != env {
continue
}
if health != "" && v.Health != health {
continue
}
if org != "" && v.Org != org {
continue
}
if driftOnly && v.Drift.Severity == SeverityOK {
continue
}
out = append(out, v)
byDrift[v.Drift.Severity]++
}
return c.JSON(http.StatusOK, map[string]any{
"apps": out,
"summary": map[string]any{
"total": len(out),
"byDrift": map[string]int{
"ok": byDrift[SeverityOK],
"yellow": byDrift[SeverityYellow],
"red": byDrift[SeverityRed],
},
},
})
}
// getApp returns one service row by CR name. Scans the platform namespaces in
// env order (main→test→dev) and returns the first match, so the bare app name
// resolves to production by default.
func (s *svc) getApp(c *zip.Ctx) error {
if err := s.ready(); err != nil {
return err
}
name := reqApp(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("app must be a DNS-1123 label")
}
for _, ns := range scanOrder() {
obj, err := s.dyn.Resource(servicesGVR).Namespace(ns).Get(c.Context(), name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
continue
}
return s.k8sErr("get", err)
}
repository, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "repository")
return c.JSON(http.StatusOK, observeCR(obj, ns, nsEnv[ns], s.runningTagOf(c.Context(), ns, name, repository)))
}
return zip.ErrNotFound("service not found in the platform namespaces")
}
// observeFleet lists every Service CR across the scanned namespaces and maps each
// to an AppView. A namespace that does not exist / is empty is skipped, never
// fatal (the fleet board must still render the reachable namespaces).
func (s *svc) observeFleet(ctx context.Context) ([]AppView, error) {
var views []AppView
for _, ns := range scanOrder() {
list, err := s.dyn.Resource(servicesGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
continue
}
return nil, s.k8sErr("list", err)
}
// Running state: one Deployment list per namespace, indexed by name — the
// running-tag source (inventory.ts). Best-effort: a Deployment RBAC/list
// error leaves runningTag empty (an honest unknown) rather than failing the
// whole board, so the declared/health/phase columns still render.
running := s.runningTagsIn(ctx, ns)
env := nsEnv[ns]
for i := range list.Items {
cr := &list.Items[i]
views = append(views, observeCR(cr, ns, env, running[cr.GetName()]))
}
}
sort.Slice(views, func(i, j int) bool {
if views[i].Org != views[j].Org {
return views[i].Org < views[j].Org
}
if views[i].App != views[j].App {
return views[i].App < views[j].App
}
return views[i].Env < views[j].Env
})
return views, nil
}
// ── deploy: merge-patch the Service CR image (deploy-executor.ts) ─────────────
// deploy rolls a new image tag onto a service by merge-patching ONLY the Service
// CR's `.spec.image`. The operator reconciles the rollout (Deployment update,
// rolling restart) — this is the exact contract deploy-executor.ts implemented,
// now native. Content-Type is JSON merge-patch (application/merge-patch+json),
// which the operator CRD accepts; the dynamic client's MergePatchType sets it.
func (s *svc) deploy(c *zip.Ctx) error {
if err := s.ready(); err != nil {
return err
}
name := reqApp(c)
if !appNameRE.MatchString(name) {
return zip.ErrBadRequest("app must be a DNS-1123 label")
}
var req struct {
Tag string `json:"tag"` // required — the new image tag (e.g. v1.1.3)
Repository string `json:"repository"` // optional — override image repo; else keep the CR's
Namespace string `json:"namespace"` // optional — target ns; else resolve to where the CR lives
}
if err := json.Unmarshal(c.Body(), &req); err != nil {
return zip.Errorf(http.StatusBadRequest, "invalid JSON body: %v", err)
}
tag := strings.TrimSpace(req.Tag)
if tag == "" {
return zip.ErrBadRequest("'tag' is required (the image tag to deploy)")
}
if strings.ContainsAny(tag, " \t\n/") || len(tag) > 128 {
return zip.ErrBadRequest("'tag' must be a single image tag (no whitespace or '/')")
}
repo := strings.TrimSpace(req.Repository)
if repo != "" && !imageRepoRE.MatchString(repo) {
return zip.ErrBadRequest("'repository' is not a valid image repository path")
}
ns := strings.TrimSpace(req.Namespace)
if ns != "" {
if _, ok := nsEnv[ns]; !ok {
return zip.ErrBadRequest("'namespace' must be a platform namespace (hanzo|hanzo-testnet|hanzo-devnet)")
}
} else {
resolved, err := s.resolveNamespace(c.Context(), name)
if err != nil {
return err
}
ns = resolved
}
// Build the merge-patch. When repository is omitted we patch only the tag +
// pullPolicy so an existing repo is preserved (JSON merge-patch merges keys,
// so omitting `repository` leaves the CR's value intact).
image := map[string]any{"tag": tag, "pullPolicy": "Always"}
if repo != "" {
image["repository"] = repo
}
patch, err := json.Marshal(map[string]any{"spec": map[string]any{"image": image}})
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "encode patch: %v", err)
}
out, err := s.dyn.Resource(servicesGVR).Namespace(ns).
Patch(c.Context(), name, k8stypes.MergePatchType, patch, metav1.PatchOptions{})
if err != nil {
switch {
case apierrors.IsNotFound(err):
return zip.ErrNotFound("service not found in namespace " + ns)
case apierrors.IsInvalid(err), apierrors.IsBadRequest(err):
return zip.Errorf(http.StatusUnprocessableEntity, "patch rejected by kubernetes: %v", err)
default:
return s.k8sErr("patch", err)
}
}
s.log.Info("deployed via Service CR patch",
"app", name, "namespace", ns, "tag", tag, "repository", repo,
"actor", c.User(), "requestID", c.RequestID())
// Read the effective repo from the patched CR (the caller may have omitted
// repository to keep the CR's existing value) so the running-tag container
// match uses the real declared repo.
effRepo, _, _ := unstructured.NestedString(out.Object, "spec", "image", "repository")
view := observeCR(out, ns, nsEnv[ns], s.runningTagOf(c.Context(), ns, name, effRepo))
return c.JSON(http.StatusOK, map[string]any{
"rolledOut": true,
"target": ns + "/" + name,
"reason": "patched Service/" + name + " image to " + tag,
"app": view,
})
}
// resolveNamespace finds the platform namespace a Service CR lives in, scanning
// in env order (main→test→dev) so a bare deploy targets production. Returns a
// clean 404 when the CR exists in none of them.
func (s *svc) resolveNamespace(ctx context.Context, name string) (string, error) {
for _, ns := range scanOrder() {
if _, err := s.dyn.Resource(servicesGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}); err == nil {
return ns, nil
} else if !apierrors.IsNotFound(err) {
return "", s.k8sErr("get", err)
}
}
return "", zip.ErrNotFound("service " + name + " not found in the platform namespaces")
}
// ── health ────────────────────────────────────────────────────────────────
// health is a REAL probe: it verifies the API server is reachable and that the
// Service CRD is served, and reports the actual state. 200 only when everything is
// ok; 503 + the real reason otherwise (never status-theater). Not admin-gated —
// liveness must be probe-able by the platform/operator without a JWT.
func (s *svc) health(c *zip.Ctx) error {
res := map[string]any{"service": "paas", "status": "ok"}
if s.dyn == nil {
res["status"], res["k8s"], res["error"] = "degraded", false, s.initErr
return c.JSON(http.StatusServiceUnavailable, res)
}
if _, err := s.dyn.Resource(servicesGVR).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err != nil {
res["status"], res["k8s"], res["crd"], res["error"] = "degraded", true, false, err.Error()
return c.JSON(http.StatusServiceUnavailable, res)
}
res["k8s"], res["crd"] = true, true
return c.JSON(http.StatusOK, res)
}
// ── k8s plumbing ────────────────────────────────────────────────────────────
func (s *svc) ready() error {
if s.dyn == nil {
return zip.Errorf(http.StatusServiceUnavailable, "paas: kubernetes client not configured: %s", s.initErr)
}
return nil
}
// k8sErr maps a raw API error to an honest gateway-level error. RBAC denials name
// the missing access so the operator knows exactly what to grant the cloud service
// account (get/list/patch on services.hanzo.ai). Mirrors ml.k8sErr.
func (s *svc) k8sErr(op string, err error) error {
s.log.Error("k8s op failed", "op", op, "resource", servicesGVR.Resource, "err", err)
if apierrors.IsForbidden(err) {
return zip.Errorf(http.StatusBadGateway,
"%s services: kubernetes RBAC denied (cloud service account needs %s on services.hanzo.ai): %v",
op, op, err)
}
return zip.Errorf(http.StatusBadGateway, "%s services failed: %v", op, err)
}
// newDynamic builds the dynamic client from the in-cluster service account,
// falling back to KUBECONFIG / ~/.kube/config for local/dev — identical to
// clients/ml.newDynamic.
func newDynamic() (dynamic.Interface, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{})
cfg, err = cc.ClientConfig()
if err != nil {
return nil, fmt.Errorf("no in-cluster config and no kubeconfig: %w", err)
}
}
cfg.UserAgent = userAgent
return dynamic.NewForConfig(cfg)
}
// ── pure mapping helpers (unit-tested without a cluster) ─────────────────────
func reqApp(c *zip.Ctx) string { return strings.ToLower(strings.TrimSpace(c.Param("app"))) }
// scanOrder returns the platform namespaces in a stable env order (main first),
// so a bare app-name read/deploy resolves to production before test/dev.
func scanOrder() []string { return []string{"hanzo", "hanzo-testnet", "hanzo-devnet"} }
// orgFromRepository derives the image namespace ("org") from an image repo:
// `ghcr.io/hanzoai/chat` → `hanzoai`; `docker.io/grafana/grafana` → `grafana`.
// Falls back to the whole repo when it has no namespace segment. Ported verbatim
// from inventory.ts `orgFromRepository`.
func orgFromRepository(repository string) string {
parts := nonEmpty(strings.Split(repository, "/"))
if len(parts) >= 3 {
return parts[1]
}
if len(parts) == 2 {
return parts[0]
}
return repository
}
// repoFromRepository derives the owner/repo GitHub coordinate from an image repo:
// `ghcr.io/hanzoai/chat` → `hanzoai/chat` (the image path minus the registry
// host). Ported verbatim from inventory.ts `repoFromRepository`.
func repoFromRepository(repository string) string {
parts := nonEmpty(strings.Split(repository, "/"))
if len(parts) >= 3 {
return strings.Join(parts[1:], "/")
}
return strings.Join(parts, "/")
}
func nonEmpty(in []string) []string {
out := in[:0]
for _, s := range in {
if s != "" {
out = append(out, s)
}
}
return out
}
// healthFromStatus rolls the operator's reconciled Service status up to the
// apps-table health vocabulary. The operator populates status.readyReplicas /
// status.replicas (and phase); we prefer that reconciled truth over re-deriving
// from the Deployment (the operator already did that join). Mirrors
// inventory.ts healthFromDeployment semantics: desired 0 ⇒ yellow (intentionally
// scaled to zero, not unhealthy), ready>=desired ⇒ green, some ready ⇒ yellow,
// none ⇒ red. Empty when the status carries no replica counts yet.
func healthFromStatus(status map[string]any) string {
desired, hasDesired := nestedInt(status, "replicas")
ready, _ := nestedInt(status, "readyReplicas")
if !hasDesired {
// Fall back to availableReplicas if the operator only reports that.
if avail, ok := nestedInt(status, "availableReplicas"); ok {
if avail > 0 {
return "green"
}
return "red"
}
return "" // no replica signal yet — unknown, never a fabricated green
}
if desired == 0 {
return "yellow"
}
if ready >= desired {
return "green"
}
if ready > 0 {
return "yellow"
}
return "red"
}
// observeCR maps one Service CR (+ its operator-reconciled status + the running
// tag observed from the live Deployment) into an AppView, attaching the drift
// verdict. This is inventory.ts observeService fused with apps-api.ts toAppView:
// declared tag from the CR spec, running tag from the Deployment (passed in),
// health + phase + endpoints from the operator-reconciled CR status.
func observeCR(obj *unstructured.Unstructured, namespace, env, runningTag string) AppView {
name := obj.GetName()
repository, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "repository")
declaredTag, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "tag")
status, _, _ := unstructured.NestedMap(obj.Object, "status")
phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase")
endpoints := nestedStringSlice(status, "endpoints")
obs := Observed{DeclaredTag: declaredTag, RunningTag: runningTag}
return AppView{
ID: orgFromRepository(repository) + "/" + name + "/" + env,
Org: orgFromRepository(repository),
App: name,
Env: env,
Repo: repoFromRepository(repository),
Registry: repository,
DeclaredTag: declaredTag,
RunningTag: runningTag,
LatestTag: "", // GH-release reader is a follow-up phase (release-reader.ts)
Health: healthFromStatus(status),
Phase: phase,
Cluster: "hanzo-k8s",
Namespace: namespace,
Endpoints: endpoints,
Drift: ComputeDrift(obs),
}
}
// runningTagsIn lists the Deployments in a namespace and returns a map of
// Deployment-name → running image tag (the container whose image repo the caller
// later matches against the CR's declared repo, in runningTagOf; here we index by
// name and keep the first container's tag as the default). Best-effort: any list
// error yields an empty map so the board still renders declared/health/phase.
func (s *svc) runningTagsIn(ctx context.Context, namespace string) map[string]string {
out := map[string]string{}
list, err := s.dyn.Resource(deploymentsGVR).Namespace(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
s.log.Warn("list deployments for running tag failed; running tag will be empty",
"namespace", namespace, "err", err)
return out
}
for i := range list.Items {
d := &list.Items[i]
out[d.GetName()] = firstContainerTag(d)
}
return out
}
// runningTagOf reads a single Deployment's running tag, matching the container
// whose image repo equals the CR's declared repo (so a sidecar like replicate/otel
// is never mistaken for the app), falling back to the first container. Mirrors
// inventory.ts runningTagFromDeployment. Best-effort: any error → "".
func (s *svc) runningTagOf(ctx context.Context, namespace, name, declaredRepository string) string {
d, err := s.dyn.Resource(deploymentsGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return ""
}
return runningTagFromDeployment(d, declaredRepository)
}
// nestedInt reads an integer-valued key from an unstructured map, tolerating the
// int64/float64 the k8s decoder may produce.
func nestedInt(m map[string]any, key string) (int, bool) {
if m == nil {
return 0, false
}
switch v := m[key].(type) {
case int64:
return int(v), true
case int:
return v, true
case float64:
return int(v), true
default:
return 0, false
}
}
// nestedStringSlice reads a []string key from an unstructured map (the k8s decoder
// yields []any of string).
func nestedStringSlice(m map[string]any, key string) []string {
if m == nil {
return nil
}
raw, ok := m[key].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, e := range raw {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
// deploymentContainers extracts the pod-template container images from an
// unstructured Deployment (spec.template.spec.containers[].image).
func deploymentContainers(dep *unstructured.Unstructured) []string {
if dep == nil {
return nil
}
raw, ok, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers")
if !ok {
return nil
}
imgs := make([]string, 0, len(raw))
for _, c := range raw {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if img, ok := cm["image"].(string); ok && img != "" {
imgs = append(imgs, img)
}
}
return imgs
}
// runningTagFromDeployment picks the running tag from a Deployment by matching the
// container whose image repository equals the CR's declared repository (so a
// sidecar can never be mistaken for the app), falling back to the first container.
// Mirrors inventory.ts runningTagFromDeployment.
func runningTagFromDeployment(dep *unstructured.Unstructured, declaredRepository string) string {
imgs := deploymentContainers(dep)
if len(imgs) == 0 {
return ""
}
for _, img := range imgs {
if repoFromImageRef(img) == declaredRepository {
return tagFromImageRef(img)
}
}
return tagFromImageRef(imgs[0])
}
// firstContainerTag is the default running tag for the namespace-indexed map: the
// first container's tag. The per-service exact match (runningTagFromDeployment)
// is used when the declared repo is known; this keeps the list pass O(deployments)
// without a Get per service.
func firstContainerTag(dep *unstructured.Unstructured) string {
imgs := deploymentContainers(dep)
if len(imgs) == 0 {
return ""
}
return tagFromImageRef(imgs[0])
}
// repoFromImageRef splits `ghcr.io/hanzoai/iam:v1` → `ghcr.io/hanzoai/iam`.
// A digest ref (`repo@sha256:…`) keeps the repo; a bare repo returns itself.
func repoFromImageRef(ref string) string {
if at := strings.LastIndex(ref, "@"); at >= 0 {
ref = ref[:at]
}
// A ':' after the last '/' is the tag separator (a ':' in a registry host:port
// segment lives before a '/', so guard on the last slash).
slash := strings.LastIndex(ref, "/")
colon := strings.LastIndex(ref, ":")
if colon > slash {
return ref[:colon]
}
return ref
}
// tagFromImageRef splits `ghcr.io/hanzoai/iam:v1` → `v1`. A digest ref returns the
// digest; a bare repo (no tag) returns "".
func tagFromImageRef(ref string) string {
if at := strings.LastIndex(ref, "@"); at >= 0 {
return ref[at+1:]
}
slash := strings.LastIndex(ref, "/")
colon := strings.LastIndex(ref, ":")
if colon > slash && colon < len(ref)-1 {
return ref[colon+1:]
}
return ""
}
+121
View File
@@ -0,0 +1,121 @@
//go:build paasintegration
// Integration probe against a REAL cluster. Not part of the normal unit suite —
// it is gated behind the `paasintegration` build tag AND requires PAAS_IT=1, so
// `go test ./...` never touches a cluster. Run explicitly:
//
// PAAS_IT=1 go test -tags paasintegration -run TestIntegration ./clients/paassvc/ -v
//
// It proves the end-to-end deploy path the standalone platform's deploy-executor
// implemented, now native in cloud:
// - observeFleet lists the operator Service CRs (the drift board) off the live
// cluster via the KUBECONFIG fallback in newDynamic.
// - an IDEMPOTENT same-image merge-patch on a low-risk service (pricing) proves
// the write path reaches the operator WITHOUT changing what runs (same tag =
// no rollout). It never mutates a tag, so it cannot perturb live state.
package paassvc
import (
"context"
"os"
"testing"
luxlog "github.com/luxfi/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8stypes "k8s.io/apimachinery/pkg/types"
)
const itService = "pricing" // low-risk service CLAUDE.md already validated
func itClient(t *testing.T) *svc {
t.Helper()
if os.Getenv("PAAS_IT") != "1" {
t.Skip("set PAAS_IT=1 to run the live-cluster integration probe")
}
dyn, err := newDynamic()
if err != nil {
t.Fatalf("newDynamic (needs a live KUBECONFIG): %v", err)
}
return &svc{dyn: dyn, log: luxlog.New("paas-it")}
}
// TestIntegrationObserveFleet lists the real fleet and asserts the board is
// non-empty and self-consistent (every row has org/app/env/registry and a drift
// verdict). It is READ-ONLY.
func TestIntegrationObserveFleet(t *testing.T) {
s := itClient(t)
views, err := s.observeFleet(context.Background())
if err != nil {
t.Fatalf("observeFleet: %v", err)
}
if len(views) == 0 {
t.Fatalf("expected a non-empty fleet board")
}
t.Logf("observed %d service rows across the platform namespaces", len(views))
var green, red, yellow int
sawPricing := false
for _, v := range views {
if v.Org == "" || v.App == "" || v.Env == "" || v.Registry == "" {
t.Errorf("incomplete row: %+v", v)
}
switch v.Drift.Severity {
case SeverityOK:
green++
case SeverityRed:
red++
case SeverityYellow:
yellow++
}
if v.App == itService && v.Env == "main" {
sawPricing = true
t.Logf("pricing row: declared=%s health=%s phase=%s drift=%s endpoints=%v",
v.DeclaredTag, v.Health, v.Phase, v.Drift.Severity, v.Endpoints)
}
}
t.Logf("drift summary: ok=%d yellow=%d red=%d", green, yellow, red)
if !sawPricing {
t.Errorf("expected to observe the %q service in ns hanzo", itService)
}
}
// TestIntegrationIdempotentDeploy proves the deploy WRITE path reaches the
// operator without changing live state: it reads pricing's CURRENT tag and
// re-patches the CR to the SAME tag. Same image ⇒ the operator sees no change ⇒
// no rollout. This exercises the exact merge-patch the deploy handler issues.
func TestIntegrationIdempotentDeploy(t *testing.T) {
s := itClient(t)
ctx := context.Background()
before, err := s.dyn.Resource(servicesGVR).Namespace("hanzo").Get(ctx, itService, metav1.GetOptions{})
if err != nil {
t.Fatalf("get %s before: %v", itService, err)
}
tag, _, _ := unstructured.NestedString(before.Object, "spec", "image", "tag")
repo, _, _ := unstructured.NestedString(before.Object, "spec", "image", "repository")
genBefore := before.GetGeneration()
t.Logf("pricing before: repo=%s tag=%s generation=%d", repo, tag, genBefore)
if tag == "" {
t.Fatalf("pricing CR has no spec.image.tag; refusing to patch")
}
// Same-image merge-patch (the identical body the deploy handler builds).
patch := []byte(`{"spec":{"image":{"tag":"` + tag + `","repository":"` + repo + `","pullPolicy":"Always"}}}`)
after, err := s.dyn.Resource(servicesGVR).Namespace("hanzo").
Patch(ctx, itService, k8stypes.MergePatchType, patch, metav1.PatchOptions{})
if err != nil {
t.Fatalf("idempotent patch: %v", err)
}
afterTag, _, _ := unstructured.NestedString(after.Object, "spec", "image", "tag")
genAfter := after.GetGeneration()
t.Logf("pricing after: tag=%s generation=%d", afterTag, genAfter)
if afterTag != tag {
t.Fatalf("tag changed by an idempotent patch: %s -> %s", tag, afterTag)
}
// A same-spec merge-patch must not bump .metadata.generation (no spec change).
if genAfter != genBefore {
t.Errorf("generation moved on a no-op patch: %d -> %d (a rollout may have been triggered)", genBefore, genAfter)
}
t.Logf("OK: deploy write-path reached the operator; live state unchanged (no rollout)")
}
+393
View File
@@ -0,0 +1,393 @@
package paassvc
import (
"reflect"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// TestServicesGVR pins the operator Service CR identity. A typo here silently
// breaks every read/deploy, so it is asserted (matches ml_test's GVR guard).
func TestServicesGVR(t *testing.T) {
want := schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "services"}
if servicesGVR != want {
t.Fatalf("servicesGVR = %v, want %v", servicesGVR, want)
}
}
// TestIsSemverTag is the exact semver policy from apps-drift.ts SEMVER_TAG:
// strictly vMAJOR.MINOR.PATCH; everything else is floating.
func TestIsSemverTag(t *testing.T) {
valid := []string{"v1.0.0", "v1.28.16", "v0.3.0", "v10.20.30", "v4.4.4"}
invalid := []string{
"", "1.0.0", "v1.0", "v1", "latest", "main", "dev", "edge",
"sha-08d2dea-amd64", "1.42.33-billing", "v1.0.0-rc1", "vX.Y.Z",
"e19980422d342f40b8ba3142e6bbba54a076fc7f", "nolux-hanzo4",
}
for _, v := range valid {
if !IsSemverTag(v) {
t.Errorf("expected %q to be a semver tag", v)
}
}
for _, v := range invalid {
if IsSemverTag(v) {
t.Errorf("expected %q to NOT be a semver tag", v)
}
}
}
// TestComputeDrift ports the apps-drift.ts contract cases 1:1 — the two
// implementations must agree exactly.
func TestComputeDrift(t *testing.T) {
cases := []struct {
name string
obs Observed
wantSev DriftSeverity
wantKinds []DriftKind
}{
{
// Clean: declared==running==latest, released with assets.
name: "fully clean",
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "v1.2.0", LatestTag: "v1.2.0", ReleaseURL: "https://x/rel", ReleaseAssets: 3},
wantSev: SeverityOK,
wantKinds: nil,
},
{
// declared behind latest → stale (yellow), plus running rolled to declared.
name: "stale only",
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "v1.2.0", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
wantSev: SeverityYellow,
wantKinds: []DriftKind{DriftStale},
},
{
// running != declared → un-rolled (yellow).
name: "un-rolled only",
obs: Observed{DeclaredTag: "v1.3.0", RunningTag: "v1.2.0", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
wantSev: SeverityYellow,
wantKinds: []DriftKind{DriftUnrolled},
},
{
// floating declared → red; stale suppressed even though latest set.
name: "floating declared suppresses stale",
obs: Observed{DeclaredTag: "sha-08d2dea", RunningTag: "sha-08d2dea", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
wantSev: SeverityRed,
wantKinds: []DriftKind{DriftFloatingDeclared, DriftFloatingRunning},
},
{
// floating running only (declared is clean semver, matches latest).
name: "floating running",
obs: Observed{DeclaredTag: "v1.3.0", RunningTag: "main", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
wantSev: SeverityRed,
wantKinds: []DriftKind{DriftFloatingRunning},
},
{
// declared semver but no GH release → no-release (red). This is the
// "all-red" state the live fleet shows today.
name: "no release",
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "v1.2.0"},
wantSev: SeverityRed,
wantKinds: []DriftKind{DriftNoRelease},
},
{
// release exists but 0 assets → zero-assets (red) — the iam class.
name: "zero assets",
obs: Observed{DeclaredTag: "v1.28.16", RunningTag: "v1.28.16", LatestTag: "v1.28.16", ReleaseURL: "https://x/rel", ReleaseAssets: 0},
wantSev: SeverityRed,
wantKinds: []DriftKind{DriftZeroAssets},
},
{
// Multiple at once: floating running + stale + no-release.
name: "compound",
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "sha-x", LatestTag: "v1.3.0"},
wantSev: SeverityRed,
wantKinds: []DriftKind{DriftFloatingRunning, DriftStale, DriftNoRelease},
},
{
// No declared tag at all → no flags (nothing to compare).
name: "no declared tag",
obs: Observed{RunningTag: "v1.2.0"},
wantSev: SeverityOK,
wantKinds: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ComputeDrift(tc.obs)
if got.Severity != tc.wantSev {
t.Errorf("severity = %q, want %q (flags=%v)", got.Severity, tc.wantSev, kinds(got.Flags))
}
if !reflect.DeepEqual(kinds(got.Flags), tc.wantKinds) {
t.Errorf("kinds = %v, want %v", kinds(got.Flags), tc.wantKinds)
}
// Flags must never be nil in the verdict (JSON `[]`, never `null`).
if got.Flags == nil {
t.Errorf("Drift.Flags must be non-nil")
}
})
}
}
func kinds(flags []DriftFlag) []DriftKind {
if len(flags) == 0 {
return nil
}
out := make([]DriftKind, 0, len(flags))
for _, f := range flags {
out = append(out, f.Kind)
}
return out
}
// TestOrgFromRepository ports inventory.ts orgFromRepository cases.
func TestOrgFromRepository(t *testing.T) {
cases := map[string]string{
"ghcr.io/hanzoai/chat": "hanzoai",
"ghcr.io/hanzoai/insights/capture": "hanzoai",
"docker.io/grafana/grafana": "grafana",
"docker.io/otel/opentelemetry-collector-contrib": "otel",
"hanzoai/iam": "hanzoai", // no registry host
"bareimage": "bareimage",
}
for repo, want := range cases {
if got := orgFromRepository(repo); got != want {
t.Errorf("orgFromRepository(%q) = %q, want %q", repo, got, want)
}
}
}
// TestRepoFromRepository ports inventory.ts repoFromRepository cases.
func TestRepoFromRepository(t *testing.T) {
cases := map[string]string{
"ghcr.io/hanzoai/chat": "hanzoai/chat",
"ghcr.io/hanzoai/insights/capture": "hanzoai/insights/capture",
"docker.io/grafana/grafana": "grafana/grafana",
"hanzoai/iam": "hanzoai/iam",
"bareimage": "bareimage",
}
for repo, want := range cases {
if got := repoFromRepository(repo); got != want {
t.Errorf("repoFromRepository(%q) = %q, want %q", repo, got, want)
}
}
}
// TestHealthFromStatus mirrors inventory.ts healthFromDeployment semantics but
// off the operator-reconciled Service status.
func TestHealthFromStatus(t *testing.T) {
cases := []struct {
name string
status map[string]any
want string
}{
{"all ready", map[string]any{"replicas": int64(2), "readyReplicas": int64(2)}, "green"},
{"partial", map[string]any{"replicas": int64(3), "readyReplicas": int64(1)}, "yellow"},
{"none ready", map[string]any{"replicas": int64(2), "readyReplicas": int64(0)}, "red"},
{"scaled to zero", map[string]any{"replicas": int64(0), "readyReplicas": int64(0)}, "yellow"},
{"available fallback ok", map[string]any{"availableReplicas": int64(2)}, "green"},
{"available fallback zero", map[string]any{"availableReplicas": int64(0)}, "red"},
{"no signal", map[string]any{"phase": "Pending"}, ""},
{"nil status", nil, ""},
{"float decode", map[string]any{"replicas": float64(2), "readyReplicas": float64(2)}, "green"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := healthFromStatus(tc.status); got != tc.want {
t.Errorf("healthFromStatus(%v) = %q, want %q", tc.status, got, tc.want)
}
})
}
}
// TestObserveCR proves the CR→AppView mapping end to end (declared tag, org/repo
// derivation, health, endpoints, drift) on a real-shaped Service CR.
func TestObserveCR(t *testing.T) {
obj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "hanzo.ai/v1",
"kind": "Service",
"metadata": map[string]any{"name": "pricing"},
"spec": map[string]any{
"image": map[string]any{"repository": "ghcr.io/hanzoai/pricing", "tag": "v1.1.2"},
},
"status": map[string]any{
"phase": "Running",
"replicas": int64(2),
"readyReplicas": int64(2),
"endpoints": []any{"https://pricing.hanzo.ai"},
},
}}
v := observeCR(obj, "hanzo", "main", "v1.1.2")
if v.ID != "hanzoai/pricing/main" {
t.Errorf("ID = %q, want hanzoai/pricing/main", v.ID)
}
if v.Org != "hanzoai" || v.App != "pricing" || v.Env != "main" {
t.Errorf("org/app/env = %q/%q/%q", v.Org, v.App, v.Env)
}
if v.Repo != "hanzoai/pricing" || v.Registry != "ghcr.io/hanzoai/pricing" {
t.Errorf("repo/registry = %q/%q", v.Repo, v.Registry)
}
if v.DeclaredTag != "v1.1.2" {
t.Errorf("declaredTag = %q, want v1.1.2", v.DeclaredTag)
}
if v.Health != "green" || v.Phase != "Running" {
t.Errorf("health/phase = %q/%q, want green/Running", v.Health, v.Phase)
}
if v.RunningTag != "v1.1.2" {
t.Errorf("runningTag = %q, want v1.1.2", v.RunningTag)
}
if !reflect.DeepEqual(v.Endpoints, []string{"https://pricing.hanzo.ai"}) {
t.Errorf("endpoints = %v", v.Endpoints)
}
// pricing v1.1.2 declared==running, semver, but no GH release wired yet →
// no-release (red). No un-rolled flag (running matches declared).
if v.Drift.Severity != SeverityRed || len(v.Drift.Flags) != 1 || v.Drift.Flags[0].Kind != DriftNoRelease {
t.Errorf("drift = %+v, want single no-release red", v.Drift)
}
}
// TestObserveCRFloating proves a floating declared tag (the commerce/cloud class)
// is flagged red.
func TestObserveCRFloating(t *testing.T) {
obj := &unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"name": "billing"},
"spec": map[string]any{"image": map[string]any{"repository": "ghcr.io/hanzoai/billing", "tag": "sha-08d2dea-amd64"}},
"status": map[string]any{"phase": "Running", "replicas": int64(1), "readyReplicas": int64(1)},
}}
v := observeCR(obj, "hanzo", "main", "sha-08d2dea-amd64")
if v.Drift.Severity != SeverityRed {
t.Fatalf("expected red for floating declared, got %q", v.Drift.Severity)
}
if kinds(v.Drift.Flags)[0] != DriftFloatingDeclared {
t.Errorf("expected floating-declared first, got %v", kinds(v.Drift.Flags))
}
}
// TestAppNameRE + imageRepoRE are the boundary injection guards for the CR name
// and deploy image repo.
func TestAppNameRE(t *testing.T) {
valid := []string{"iam", "cloud", "commerce-admin", "insights-kv", "world-gw", "a"}
invalid := []string{"", "-iam", "iam-", "IAM", "i am", "iam/x", "iam.x", "iam_x"}
for _, v := range valid {
if !appNameRE.MatchString(v) {
t.Errorf("expected %q valid app name", v)
}
}
for _, v := range invalid {
if appNameRE.MatchString(v) {
t.Errorf("expected %q invalid app name", v)
}
}
}
func TestImageRepoRE(t *testing.T) {
valid := []string{"ghcr.io/hanzoai/iam", "docker.io/grafana/grafana", "ghcr.io/hanzoai/insights/capture"}
invalid := []string{"", "ghcr.io/hanzoai/iam ", " ghcr.io/x", "GHCR.io/x", "ghcr.io/hanzoai/iam:tag"}
for _, v := range valid {
if !imageRepoRE.MatchString(v) {
t.Errorf("expected %q valid repo", v)
}
}
for _, v := range invalid {
if imageRepoRE.MatchString(v) {
t.Errorf("expected %q invalid repo", v)
}
}
}
// TestDeploymentsGVR pins the Deployment GVR (the running-tag source).
func TestDeploymentsGVR(t *testing.T) {
want := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
if deploymentsGVR != want {
t.Fatalf("deploymentsGVR = %v, want %v", deploymentsGVR, want)
}
}
// TestImageRefSplit proves repo/tag extraction across tag, digest, host:port, and
// bare forms — the running-tag parse (inventory.ts parseImageRef).
func TestImageRefSplit(t *testing.T) {
cases := []struct {
ref string
wantRepo string
wantTag string
}{
{"ghcr.io/hanzoai/iam:v1.28.16", "ghcr.io/hanzoai/iam", "v1.28.16"},
{"ghcr.io/hanzoai/cloud:1.785.32", "ghcr.io/hanzoai/cloud", "1.785.32"},
{"docker.io/otel/opentelemetry-collector-contrib:0.154.0", "docker.io/otel/opentelemetry-collector-contrib", "0.154.0"},
{"registry:5000/hanzoai/iam:v1", "registry:5000/hanzoai/iam", "v1"}, // host:port must not be read as tag
{"ghcr.io/hanzoai/iam@sha256:abc123", "ghcr.io/hanzoai/iam", "sha256:abc123"},
{"ghcr.io/hanzoai/iam", "ghcr.io/hanzoai/iam", ""}, // no tag
}
for _, tc := range cases {
if got := repoFromImageRef(tc.ref); got != tc.wantRepo {
t.Errorf("repoFromImageRef(%q) = %q, want %q", tc.ref, got, tc.wantRepo)
}
if got := tagFromImageRef(tc.ref); got != tc.wantTag {
t.Errorf("tagFromImageRef(%q) = %q, want %q", tc.ref, got, tc.wantTag)
}
}
}
// TestRunningTagFromDeployment proves the container match ignores sidecars and
// falls back to the first container (inventory.ts runningTagFromDeployment).
func TestRunningTagFromDeployment(t *testing.T) {
dep := &unstructured.Unstructured{Object: map[string]any{
"spec": map[string]any{"template": map[string]any{"spec": map[string]any{"containers": []any{
map[string]any{"name": "replicate", "image": "ghcr.io/hanzoai/replicate:v9"}, // sidecar first
map[string]any{"name": "app", "image": "ghcr.io/hanzoai/iam:v1.28.16"},
}}}},
}}
// Exact repo match picks the app container, not the sidecar.
if got := runningTagFromDeployment(dep, "ghcr.io/hanzoai/iam"); got != "v1.28.16" {
t.Errorf("matched tag = %q, want v1.28.16 (must skip sidecar)", got)
}
// Unknown repo → first container fallback.
if got := runningTagFromDeployment(dep, "ghcr.io/hanzoai/unknown"); got != "v9" {
t.Errorf("fallback tag = %q, want v9 (first container)", got)
}
// No containers → empty.
empty := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{}}}
if got := runningTagFromDeployment(empty, "x"); got != "" {
t.Errorf("no-containers tag = %q, want empty", got)
}
}
// TestObserveCRUnrolled proves the un-rolled flag fires when the running tag lags
// the declared tag — the core value of the Deployment join.
func TestObserveCRUnrolled(t *testing.T) {
obj := &unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"name": "iam"},
"spec": map[string]any{"image": map[string]any{"repository": "ghcr.io/hanzoai/iam", "tag": "v1.28.16"}},
"status": map[string]any{"phase": "Running", "replicas": int64(2), "readyReplicas": int64(2)},
}}
// declared v1.28.16, running v1.28.15 → un-rolled (yellow) + no-release (red).
v := observeCR(obj, "hanzo", "main", "v1.28.15")
if v.Drift.Severity != SeverityRed {
t.Fatalf("severity = %q, want red (no-release dominates)", v.Drift.Severity)
}
ks := kinds(v.Drift.Flags)
hasUnrolled := false
for _, k := range ks {
if k == DriftUnrolled {
hasUnrolled = true
}
}
if !hasUnrolled {
t.Errorf("expected un-rolled flag for running!=declared, got %v", ks)
}
}
// TestScanOrder pins production-first namespace ordering (a bare deploy targets
// main).
func TestScanOrder(t *testing.T) {
got := scanOrder()
want := []string{"hanzo", "hanzo-testnet", "hanzo-devnet"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("scanOrder = %v, want %v", got, want)
}
for _, ns := range got {
if _, ok := nsEnv[ns]; !ok {
t.Errorf("scanOrder namespace %q missing from nsEnv map", ns)
}
}
}
+7
View File
@@ -53,6 +53,13 @@ import (
// the deploy pipeline (artifact/git → OUR S3 → live URL).
_ "github.com/hanzoai/cloud/clients/projectsvc" // order 125 — /v1/projects/*
// PaaS control plane: the native, in-process port of the standalone Dokploy
// platform's deploy lifecycle. Reads the operator `Service` CR fleet as the
// declared/running/drift board and deploys by merge-patching a CR's
// `.spec.image` (the operator reconciles the rollout) — the ONE deploy path.
// Global-admin only; the user-facing view lives in console2.
_ "github.com/hanzoai/cloud/clients/paassvc" // order 128 — /v1/paas/*
// ML/Train control plane: tenant-scoped k8s bridge fronting the kubeflow
// forks (kserve InferenceService, trainer TrainJob, katib Experiment).
_ "github.com/hanzoai/cloud/clients/ml" // order 130 — /v1/ml/*,/v1/train/*