go/doc concatenates EVERY comment attached to a `package` clause, in file
order — so a file header on an alphabetically-earlier file becomes the
package doc. `go doc ./apps/visor` opened "board.go — GET /v1/fleet"; platform
opened with applylive.go, deploy with actions.go. Detach the 43 file headers
with a blank line so the one product paragraph is the whole package doc — the
text that becomes the OpenAPI tag, the MCP door prose and the CLI group help.
Four docs named the wrong thing or claimed something untrue:
deploy said "Package gitops" — it is Hanzo CD at /v1/deploy
goja said "Package gojahost"
do said DigitalOcean is Hanzo's EXCLUSIVE cloud venue; venue links
DigitalOcean, AWS, GCP and Azure. do is the HOUSE account
membership cited cloud.SetLiveSource and apps/ installing it; both are gone
(22f4fc64) and nothing calls K8s — say so, the outage it fixes is
not fixed
And platform pointed at a Goa design module that emits 15 ops against the 30
the router registers — a second source that has already drifted; say which one
is the contract. clients/<app> paths in these docs are now apps/<app>.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
209 lines
9.2 KiB
Go
209 lines
9.2 KiB
Go
// gitops.go — GET /v1/deploy/gitops: the CD plane's OWN state, read from Hanzo
|
|
// CD's Application CRs (apps.hanzo.ai/v1alpha1, k8s.CDApplications).
|
|
//
|
|
// This is deliberately NOT another workload board. Every other read in this
|
|
// package projects operator App CRs — one row per workload, its declared vs
|
|
// running image tag. A CD Application is the layer ABOVE that: the git source CD
|
|
// polls, the commit it last applied, and the deploys it has performed. The two
|
|
// disagree in exactly the case an operator most needs to see — main carries a new
|
|
// image pin, CD has not applied that commit yet, so every App CR still declares
|
|
// the old tag and the drift board is legitimately "Synced" while the deploy has
|
|
// not landed. Reading the App CRs can never surface that; only the Application's
|
|
// applied revision can.
|
|
//
|
|
// `history` IS the recent-delivery feed: Hanzo CD records each applied revision
|
|
// with its start/finish, so the deploy log needs no second store and cannot drift
|
|
// from what actually happened.
|
|
//
|
|
// SuperAdmin-gated (guard) like the rest of the dashboard writes and bootstrap:
|
|
// the CD plane is fleet infrastructure with no tenant dimension. READ-ONLY — the
|
|
// operator view observes CD, it never drives it (sync policy here is `automated`
|
|
// with selfHeal, so the plane reconciles itself; the actionable verb an operator
|
|
// has is the per-app reconcile already served by dashSync).
|
|
|
|
package deploy
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
|
|
"github.com/hanzoai/cloud/apps/k8s"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
)
|
|
|
|
// gitOpsHistoryMax caps the per-Application deploy log this endpoint returns.
|
|
// Hanzo CD already keeps a bounded history; this only bounds the response.
|
|
const gitOpsHistoryMax = 10
|
|
|
|
// GitOpsDeploy is one revision Hanzo CD actually applied.
|
|
type GitOpsDeploy struct {
|
|
ID int64 `json:"id"`
|
|
Revision string `json:"revision"`
|
|
StartedAt string `json:"startedAt,omitempty"`
|
|
DeployedAt string `json:"deployedAt,omitempty"`
|
|
Automated bool `json:"automated"`
|
|
}
|
|
|
|
// GitOpsOperation is the LAST sync operation and how it ended — the honest answer
|
|
// to "did the most recent attempt succeed", which the sync verdict alone does not
|
|
// give (an Application is "Synced" to whatever revision it managed to apply).
|
|
type GitOpsOperation struct {
|
|
Phase string `json:"phase"`
|
|
Message string `json:"message,omitempty"`
|
|
StartedAt string `json:"startedAt,omitempty"`
|
|
FinishedAt string `json:"finishedAt,omitempty"`
|
|
Revision string `json:"revision,omitempty"`
|
|
}
|
|
|
|
// GitOpsApp is one CD Application: what it tracks, what it has applied, and how
|
|
// that went.
|
|
type GitOpsApp struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Project string `json:"project,omitempty"`
|
|
RepoURL string `json:"repoURL,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
TargetRevision string `json:"targetRevision,omitempty"`
|
|
Revision string `json:"revision,omitempty"` // the commit last applied
|
|
Sync string `json:"sync"` // Synced|OutOfSync|Unknown
|
|
Health string `json:"health"` // Healthy|Degraded|Progressing|…
|
|
ReconciledAt string `json:"reconciledAt,omitempty"`
|
|
Automated bool `json:"automated"`
|
|
SelfHeal bool `json:"selfHeal"`
|
|
Resources int `json:"resources"`
|
|
Operation *GitOpsOperation `json:"operation,omitempty"`
|
|
History []GitOpsDeploy `json:"history"`
|
|
}
|
|
|
|
// GitOpsPlane is the reply. `installed` is false — with an empty list and a
|
|
// reason — when the CD CRD is not served in this cluster. That is a FACT about
|
|
// the cluster, not a failure of this request, so the caller can say "no CD plane
|
|
// here" instead of rendering an error it cannot act on. A genuine transport or
|
|
// RBAC failure still errors (k8sErr).
|
|
type GitOpsPlane struct {
|
|
Installed bool `json:"installed"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Applications []GitOpsApp `json:"applications"`
|
|
}
|
|
|
|
// GetDeployGitOps lists every Hanzo CD Application in the cluster: the git source
|
|
// each one polls, the commit it last APPLIED, how its last sync operation ended,
|
|
// and its recent deploy history — newest deploy first, ordered by namespace then
|
|
// name.
|
|
//
|
|
// This is the layer ABOVE the application board, and the two disagree in exactly
|
|
// the case an operator most needs to see: main carries a new image pin, CD has
|
|
// not applied that commit yet, so every App CR still declares the old tag and the
|
|
// application board is legitimately "Synced" while the deploy has not landed.
|
|
// Only the applied revision here can show that.
|
|
//
|
|
// installed is false — with a reason and an empty list — when the CD CRD is not
|
|
// served in this cluster. That is a FACT about the cluster rather than a failure
|
|
// of the request, so the caller can say "no CD plane here" instead of rendering
|
|
// an error it cannot act on; a genuine transport or RBAC failure still errors.
|
|
//
|
|
// Read-only, and platform SuperAdmin only: the CD plane is fleet infrastructure
|
|
// with no tenant dimension. This view observes CD and never drives it — the sync
|
|
// policy is automated with self-heal, and the actionable verb an operator has is
|
|
// the per-application reconcile at POST /v1/deploy/applications/{name}/sync.
|
|
func (o ops) gitops(ctx context.Context, _ *noInput) (*GitOpsPlane, error) {
|
|
if _, err := superAdminOf(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ready(o.s); err != nil {
|
|
return nil, err
|
|
}
|
|
// Cluster-wide: CD Applications live in the controller's namespace, and which
|
|
// namespace that is, is CD's business — not a constant this plane should hold.
|
|
list, err := o.s.State.dyn.Resource(k8s.CDApplications).Namespace("").List(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return &GitOpsPlane{
|
|
Reason: "Hanzo CD is not installed in this cluster (no apps.hanzo.ai/Application CRD)",
|
|
Applications: []GitOpsApp{},
|
|
}, nil
|
|
}
|
|
return nil, k8sErr(o.s, "list", err)
|
|
}
|
|
apps := make([]GitOpsApp, 0, len(list.Items))
|
|
for i := range list.Items {
|
|
apps = append(apps, observeGitOpsApp(&list.Items[i]))
|
|
}
|
|
sort.Slice(apps, func(i, j int) bool {
|
|
if apps[i].Namespace != apps[j].Namespace {
|
|
return apps[i].Namespace < apps[j].Namespace
|
|
}
|
|
return apps[i].Name < apps[j].Name
|
|
})
|
|
return &GitOpsPlane{Installed: true, Applications: apps}, nil
|
|
}
|
|
|
|
// observeGitOpsApp maps one Application CR to its view. Every field is read
|
|
// verbatim — the vocabulary (Synced/Healthy/Succeeded) is CD's own, so the
|
|
// console renders what CD said rather than a re-derivation that could disagree.
|
|
func observeGitOpsApp(cr *unstructured.Unstructured) GitOpsApp {
|
|
obj := cr.Object
|
|
str := func(fields ...string) string { v, _, _ := unstructured.NestedString(obj, fields...); return v }
|
|
bl := func(fields ...string) bool { v, _, _ := unstructured.NestedBool(obj, fields...); return v }
|
|
|
|
automated, hasAutomated, _ := unstructured.NestedMap(obj, "spec", "syncPolicy", "automated")
|
|
resources, _, _ := unstructured.NestedSlice(obj, "status", "resources")
|
|
|
|
app := GitOpsApp{
|
|
Name: cr.GetName(),
|
|
Namespace: cr.GetNamespace(),
|
|
Project: str("spec", "project"),
|
|
RepoURL: str("spec", "source", "repoURL"),
|
|
Path: str("spec", "source", "path"),
|
|
TargetRevision: str("spec", "source", "targetRevision"),
|
|
Revision: str("status", "sync", "revision"),
|
|
Sync: str("status", "sync", "status"),
|
|
Health: str("status", "health", "status"),
|
|
ReconciledAt: str("status", "reconciledAt"),
|
|
Automated: hasAutomated && automated != nil,
|
|
SelfHeal: bl("spec", "syncPolicy", "automated", "selfHeal"),
|
|
Resources: len(resources),
|
|
History: gitOpsHistory(obj),
|
|
}
|
|
if phase := str("status", "operationState", "phase"); phase != "" {
|
|
app.Operation = &GitOpsOperation{
|
|
Phase: phase,
|
|
Message: str("status", "operationState", "message"),
|
|
StartedAt: str("status", "operationState", "startedAt"),
|
|
FinishedAt: str("status", "operationState", "finishedAt"),
|
|
Revision: str("status", "operationState", "syncResult", "revision"),
|
|
}
|
|
}
|
|
return app
|
|
}
|
|
|
|
// gitOpsHistory reads status.history newest-first, capped. CD appends oldest-first
|
|
// and bounds the list itself; reversing here means the caller never has to know
|
|
// the storage order to show "what shipped last".
|
|
func gitOpsHistory(obj map[string]any) []GitOpsDeploy {
|
|
raw, _, _ := unstructured.NestedSlice(obj, "status", "history")
|
|
out := make([]GitOpsDeploy, 0, len(raw))
|
|
for i := len(raw) - 1; i >= 0 && len(out) < gitOpsHistoryMax; i-- {
|
|
entry, ok := raw[i].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _, _ := unstructured.NestedInt64(entry, "id")
|
|
rev, _, _ := unstructured.NestedString(entry, "revision")
|
|
started, _, _ := unstructured.NestedString(entry, "deployStartedAt")
|
|
deployed, _, _ := unstructured.NestedString(entry, "deployedAt")
|
|
automated, _, _ := unstructured.NestedBool(entry, "initiatedBy", "automated")
|
|
out = append(out, GitOpsDeploy{
|
|
ID: id,
|
|
Revision: rev,
|
|
StartedAt: started,
|
|
DeployedAt: deployed,
|
|
Automated: automated,
|
|
})
|
|
}
|
|
return out
|
|
}
|