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