Files
zeekayandHanzo Dev 7cbe76b386 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers, and int32Ptr went once new(expr) could say it; omitzero is not
applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:07:47 -07:00

1274 lines
40 KiB
Go

package manifests
import (
"fmt"
"maps"
"strconv"
"strings"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
policyv1 "k8s.io/api/policy/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"github.com/hanzoai/nchain/api/v1alpha1"
"github.com/hanzoai/nchain/internal/protocol"
)
// BuildNodeClusterStatefulSet creates a StatefulSet for the blockchain node cluster.
func BuildNodeClusterStatefulSet(cluster *v1alpha1.NodeCluster, driver protocol.Driver) *appsv1.StatefulSet {
labels := StandardLabels(cluster.Name, "node", cluster.Name, cluster.Spec.Image.Tag)
labels = MergeLabels(labels, cluster.Spec.Labels)
selectorLbls := SelectorLabels(cluster.Name)
// Determine container image.
image := fmt.Sprintf("%s:%s", cluster.Spec.Image.Repository, tagOrDefault(cluster.Spec.Image.Tag))
// Build command/args from driver.
command, args := driver.BuildCommand(&cluster.Spec)
// Build environment.
env := driver.BuildEnv(&cluster.Spec)
env = append(env, cluster.Spec.Env...)
// Build volume mounts.
volumeMounts := driver.BuildVolumeMounts(&cluster.Spec)
// Build health probes.
healthPath, healthPort := driver.HealthEndpoint(&cluster.Spec)
readyPath, readyPort := driver.ReadinessEndpoint(&cluster.Spec)
container := corev1.Container{
Name: "node",
Image: image,
Command: command,
Args: args,
Env: env,
EnvFrom: cluster.Spec.EnvFrom,
VolumeMounts: volumeMounts,
Ports: buildContainerPorts(cluster, driver),
LivenessProbe: &corev1.Probe{
HTTPGet: &corev1.HTTPGetAction{
Path: healthPath,
Port: intstr.FromInt32(healthPort),
},
InitialDelaySeconds: 30,
PeriodSeconds: 30,
TimeoutSeconds: 5,
FailureThreshold: 3,
},
ReadinessProbe: &corev1.Probe{
HTTPGet: &corev1.HTTPGetAction{
Path: readyPath,
Port: intstr.FromInt32(readyPort),
},
InitialDelaySeconds: 10,
PeriodSeconds: 10,
TimeoutSeconds: 5,
FailureThreshold: 3,
},
ImagePullPolicy: cluster.Spec.Image.PullPolicy,
}
// Apply resource requirements from spec, falling back to driver recommendations.
if cluster.Spec.Resources != nil {
container.Resources = corev1.ResourceRequirements{
Requests: cluster.Spec.Resources.Requests,
Limits: cluster.Spec.Resources.Limits,
}
} else {
requests, limits := driver.RecommendedResources(cluster.Spec.Role)
container.Resources = corev1.ResourceRequirements{
Requests: requests,
Limits: limits,
}
}
// Pre-stop hook for graceful shutdown.
container.Lifecycle = &corev1.Lifecycle{
PreStop: &corev1.LifecycleHandler{
Exec: &corev1.ExecAction{
Command: []string{"/bin/sh", "-c", "sleep 5"},
},
},
}
// Build volumes.
volumes := driver.BuildVolumes(&cluster.Spec)
// Fix ConfigMap volume name reference.
for i := range volumes {
if volumes[i].Name == "config" && volumes[i].ConfigMap != nil {
volumes[i].ConfigMap.Name = cluster.Name + "-config"
}
}
// Init containers from driver.
initContainers := driver.BuildInitContainers(&cluster.Spec)
// PVC template for chain data. Use spec storage if provided, otherwise driver recommendation.
var pvcTemplates []corev1.PersistentVolumeClaim
if cluster.Spec.Storage != nil {
pvcTemplates = append(pvcTemplates, corev1.PersistentVolumeClaim{
Name: "data",
Labels: selectorLbls,
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: cluster.Spec.Storage.Size,
},
},
StorageClassName: &cluster.Spec.Storage.StorageClassName,
},
})
} else if recStorage := driver.RecommendedStorage(cluster.Spec.Role); recStorage != "" {
pvcTemplates = append(pvcTemplates, corev1.PersistentVolumeClaim{
Name: "data",
Labels: selectorLbls,
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse(recStorage),
},
},
},
})
}
// Security context.
var podSecCtx *corev1.PodSecurityContext
if cluster.Spec.Security.RunAsUser != nil || cluster.Spec.Security.RunAsGroup != nil || cluster.Spec.Security.FSGroup != nil {
podSecCtx = &corev1.PodSecurityContext{
RunAsUser: cluster.Spec.Security.RunAsUser,
RunAsGroup: cluster.Spec.Security.RunAsGroup,
FSGroup: cluster.Spec.Security.FSGroup,
}
}
// Image pull secrets.
var pullSecrets []corev1.LocalObjectReference
for _, s := range cluster.Spec.ImagePullSecrets {
pullSecrets = append(pullSecrets, corev1.LocalObjectReference{Name: s})
}
terminationGrace := int64(60)
// Update strategy.
updateStrategy := appsv1.StatefulSetUpdateStrategy{
Type: appsv1.RollingUpdateStatefulSetStrategyType,
}
if cluster.Spec.UpgradeStrategy.Type == v1alpha1.UpgradeOnDelete {
updateStrategy.Type = appsv1.OnDeleteStatefulSetStrategyType
}
return &appsv1.StatefulSet{
Name: cluster.Name,
Namespace: cluster.Namespace,
Labels: labels,
Annotations: cluster.Spec.Annotations,
Spec: appsv1.StatefulSetSpec{
Replicas: cluster.Spec.Replicas,
ServiceName: cluster.Name + "-headless",
MinReadySeconds: 10,
UpdateStrategy: updateStrategy,
VolumeClaimTemplates: pvcTemplates,
Selector: &metav1.LabelSelector{
MatchLabels: selectorLbls,
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: MergeLabels(labels, selectorLbls),
Annotations: cluster.Spec.Annotations,
},
Spec: corev1.PodSpec{
InitContainers: initContainers,
Containers: []corev1.Container{container},
Volumes: volumes,
ImagePullSecrets: pullSecrets,
SecurityContext: podSecCtx,
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
}
// BuildNodeClusterHeadlessService creates a headless Service for StatefulSet DNS.
func BuildNodeClusterHeadlessService(cluster *v1alpha1.NodeCluster) *corev1.Service {
labels := StandardLabels(cluster.Name, "node", cluster.Name, "")
selectorLbls := SelectorLabels(cluster.Name)
return &corev1.Service{
Name: cluster.Name + "-headless",
Namespace: cluster.Namespace,
Labels: labels,
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
ClusterIP: "None",
Selector: selectorLbls,
Ports: buildServicePorts(cluster),
},
}
}
// BuildNodeClusterService creates a ClusterIP Service for external access.
func BuildNodeClusterService(cluster *v1alpha1.NodeCluster) *corev1.Service {
labels := StandardLabels(cluster.Name, "node", cluster.Name, "")
selectorLbls := SelectorLabels(cluster.Name)
return &corev1.Service{
Name: cluster.Name,
Namespace: cluster.Namespace,
Labels: labels,
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: selectorLbls,
Ports: buildServicePorts(cluster),
},
}
}
// BuildNodeClusterConfigMap creates a ConfigMap with protocol-specific configuration.
func BuildNodeClusterConfigMap(cluster *v1alpha1.NodeCluster, driver protocol.Driver) *corev1.ConfigMap {
labels := StandardLabels(cluster.Name, "config", cluster.Name, "")
return &corev1.ConfigMap{
Name: cluster.Name + "-config",
Namespace: cluster.Namespace,
Labels: labels,
Data: driver.BuildConfigMap(&cluster.Spec),
}
}
// BuildIndexerDeployment creates a Deployment for a blockchain data indexer.
func BuildIndexerDeployment(indexer *v1alpha1.Indexer) *appsv1.Deployment {
labels := StandardLabels(indexer.Name, "indexer", "", indexer.Spec.Image.Tag)
selectorLbls := SelectorLabels(indexer.Name)
image := fmt.Sprintf("%s:%s", indexer.Spec.Image.Repository, tagOrDefault(indexer.Spec.Image.Tag))
container := corev1.Container{
Name: "indexer",
Image: image,
ImagePullPolicy: indexer.Spec.Image.PullPolicy,
Env: []corev1.EnvVar{
{Name: "CHAIN_REF", Value: indexer.Spec.ChainRef},
{Name: "NODE_CLUSTER_REF", Value: indexer.Spec.NodeClusterRef},
},
}
if indexer.Spec.Resources != nil {
container.Resources = corev1.ResourceRequirements{
Requests: indexer.Spec.Resources.Requests,
Limits: indexer.Spec.Resources.Limits,
}
}
if indexer.Spec.Storage != nil {
container.VolumeMounts = []corev1.VolumeMount{
{Name: "data", MountPath: "/data"},
}
}
replicas := int32(1)
if indexer.Spec.Replicas != nil {
replicas = *indexer.Spec.Replicas
}
terminationGrace := int64(30)
dep := &appsv1.Deployment{
Name: indexer.Name,
Namespace: indexer.Namespace,
Labels: labels,
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: selectorLbls,
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: MergeLabels(labels, selectorLbls),
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{container},
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
return dep
}
// BuildExplorerDeployments creates backend and (optionally) frontend Deployments.
func BuildExplorerDeployments(explorer *v1alpha1.Explorer) (*appsv1.Deployment, *appsv1.Deployment) {
backendLabels := StandardLabels(explorer.Name+"-backend", "explorer-backend", explorer.Name, explorer.Spec.BackendImage.Tag)
backendSelector := SelectorLabels(explorer.Name + "-backend")
backendImage := fmt.Sprintf("%s:%s", explorer.Spec.BackendImage.Repository, tagOrDefault(explorer.Spec.BackendImage.Tag))
backendContainer := corev1.Container{
Name: "backend",
Image: backendImage,
ImagePullPolicy: explorer.Spec.BackendImage.PullPolicy,
Env: []corev1.EnvVar{
{Name: "CHAIN_REF", Value: explorer.Spec.ChainRef},
},
}
if explorer.Spec.Resources != nil {
backendContainer.Resources = corev1.ResourceRequirements{
Requests: explorer.Spec.Resources.Requests,
Limits: explorer.Spec.Resources.Limits,
}
}
one := int32(1)
terminationGrace := int64(30)
backend := &appsv1.Deployment{
Name: explorer.Name + "-backend",
Namespace: explorer.Namespace,
Labels: backendLabels,
Spec: appsv1.DeploymentSpec{
Replicas: &one,
Selector: &metav1.LabelSelector{MatchLabels: backendSelector},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: MergeLabels(backendLabels, backendSelector)},
Spec: corev1.PodSpec{
Containers: []corev1.Container{backendContainer},
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
// Frontend deployment (optional).
var frontend *appsv1.Deployment
if explorer.Spec.FrontendImage != nil {
frontendLabels := StandardLabels(explorer.Name+"-frontend", "explorer-frontend", explorer.Name, explorer.Spec.FrontendImage.Tag)
frontendSelector := SelectorLabels(explorer.Name + "-frontend")
frontendImage := fmt.Sprintf("%s:%s", explorer.Spec.FrontendImage.Repository, tagOrDefault(explorer.Spec.FrontendImage.Tag))
frontendContainer := corev1.Container{
Name: "frontend",
Image: frontendImage,
ImagePullPolicy: explorer.Spec.FrontendImage.PullPolicy,
}
frontend = &appsv1.Deployment{
Name: explorer.Name + "-frontend",
Namespace: explorer.Namespace,
Labels: frontendLabels,
Spec: appsv1.DeploymentSpec{
Replicas: &one,
Selector: &metav1.LabelSelector{MatchLabels: frontendSelector},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: MergeLabels(frontendLabels, frontendSelector)},
Spec: corev1.PodSpec{
Containers: []corev1.Container{frontendContainer},
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
}
return backend, frontend
}
// BuildGatewayDeployment creates a Deployment for the RPC/API gateway.
func BuildGatewayDeployment(gw *v1alpha1.Gateway) *appsv1.Deployment {
image := "ghcr.io/hanzoai/gateway:latest"
if gw.Spec.Image != nil {
image = fmt.Sprintf("%s:%s", gw.Spec.Image.Repository, tagOrDefault(gw.Spec.Image.Tag))
}
labels := StandardLabels(gw.Name, "gateway", "", "")
selectorLbls := SelectorLabels(gw.Name)
container := corev1.Container{
Name: "gateway",
Image: image,
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 8080, Protocol: corev1.ProtocolTCP},
},
VolumeMounts: []corev1.VolumeMount{
{Name: "config", MountPath: "/etc/krakend", ReadOnly: true},
},
}
if gw.Spec.Resources != nil {
container.Resources = corev1.ResourceRequirements{
Requests: gw.Spec.Resources.Requests,
Limits: gw.Spec.Resources.Limits,
}
}
if gw.Spec.Image != nil {
container.ImagePullPolicy = gw.Spec.Image.PullPolicy
}
replicas := int32(2)
if gw.Spec.Replicas != nil {
replicas = *gw.Spec.Replicas
}
terminationGrace := int64(30)
return &appsv1.Deployment{
Name: gw.Name,
Namespace: gw.Namespace,
Labels: labels,
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: selectorLbls},
Strategy: appsv1.DeploymentStrategy{
Type: appsv1.RollingUpdateDeploymentStrategyType,
RollingUpdate: &appsv1.RollingUpdateDeployment{
MaxSurge: intStrPtr(1),
MaxUnavailable: intStrPtr(0),
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: MergeLabels(labels, selectorLbls)},
Spec: corev1.PodSpec{
Containers: []corev1.Container{container},
Volumes: []corev1.Volume{
{
Name: "config",
ConfigMap: &corev1.ConfigMapVolumeSource{
Name: gw.Name + "-config",
},
},
},
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
}
// BuildGatewayConfigMap creates a ConfigMap with KrakenD gateway configuration.
func BuildGatewayConfigMap(gw *v1alpha1.Gateway) *corev1.ConfigMap {
labels := StandardLabels(gw.Name, "gateway-config", "", "")
// Build KrakenD-compatible JSON configuration from routes.
config := buildKrakenDConfig(gw)
return &corev1.ConfigMap{
Name: gw.Name + "-config",
Namespace: gw.Namespace,
Labels: labels,
Data: map[string]string{
"krakend.json": config,
},
}
}
// BuildBridgeDeployments creates server and (optionally) UI Deployments.
func BuildBridgeDeployments(bridge *v1alpha1.Bridge) (*appsv1.Deployment, *appsv1.Deployment) {
serverLabels := StandardLabels(bridge.Name+"-server", "bridge-server", bridge.Name, bridge.Spec.ServerImage.Tag)
serverSelector := SelectorLabels(bridge.Name + "-server")
serverImage := fmt.Sprintf("%s:%s", bridge.Spec.ServerImage.Repository, tagOrDefault(bridge.Spec.ServerImage.Tag))
serverContainer := corev1.Container{
Name: "server",
Image: serverImage,
ImagePullPolicy: bridge.Spec.ServerImage.PullPolicy,
Env: []corev1.EnvVar{
{Name: "SOURCE_CHAIN_REF", Value: bridge.Spec.SourceChainRef},
{Name: "TARGET_CHAIN_REF", Value: bridge.Spec.TargetChainRef},
},
}
if bridge.Spec.Resources != nil {
serverContainer.Resources = corev1.ResourceRequirements{
Requests: bridge.Spec.Resources.Requests,
Limits: bridge.Spec.Resources.Limits,
}
}
one := int32(1)
terminationGrace := int64(30)
server := &appsv1.Deployment{
Name: bridge.Name + "-server",
Namespace: bridge.Namespace,
Labels: serverLabels,
Spec: appsv1.DeploymentSpec{
Replicas: &one,
Selector: &metav1.LabelSelector{MatchLabels: serverSelector},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: MergeLabels(serverLabels, serverSelector)},
Spec: corev1.PodSpec{
Containers: []corev1.Container{serverContainer},
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
var ui *appsv1.Deployment
if bridge.Spec.UIImage != nil {
uiLabels := StandardLabels(bridge.Name+"-ui", "bridge-ui", bridge.Name, bridge.Spec.UIImage.Tag)
uiSelector := SelectorLabels(bridge.Name + "-ui")
uiImage := fmt.Sprintf("%s:%s", bridge.Spec.UIImage.Repository, tagOrDefault(bridge.Spec.UIImage.Tag))
uiContainer := corev1.Container{
Name: "ui",
Image: uiImage,
ImagePullPolicy: bridge.Spec.UIImage.PullPolicy,
}
ui = &appsv1.Deployment{
Name: bridge.Name + "-ui",
Namespace: bridge.Namespace,
Labels: uiLabels,
Spec: appsv1.DeploymentSpec{
Replicas: &one,
Selector: &metav1.LabelSelector{MatchLabels: uiSelector},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: MergeLabels(uiLabels, uiSelector)},
Spec: corev1.PodSpec{
Containers: []corev1.Container{uiContainer},
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
}
return server, ui
}
// BuildIngress creates an Ingress resource with cert-manager annotations.
func BuildIngress(name, ns string, spec *v1alpha1.IngressSpec, serviceName string, servicePort int32, labels map[string]string) *networkingv1.Ingress {
pathType := networkingv1.PathTypePrefix
annotations := map[string]string{}
if spec.TLS {
annotations["cert-manager.io/cluster-issuer"] = spec.ClusterIssuer
}
maps.Copy(annotations, spec.Annotations)
var rules []networkingv1.IngressRule
for _, host := range spec.Hosts {
rules = append(rules, networkingv1.IngressRule{
Host: host,
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: serviceName,
Port: networkingv1.ServiceBackendPort{Number: servicePort},
},
},
},
},
},
})
}
var tls []networkingv1.IngressTLS
if spec.TLS && len(spec.Hosts) > 0 {
tls = tlsPerRegisteredDomain(name, spec.Hosts)
}
ing := &networkingv1.Ingress{
Name: name,
Namespace: ns,
Labels: labels,
Annotations: annotations,
Spec: networkingv1.IngressSpec{
Rules: rules,
TLS: tls,
},
}
if spec.IngressClassName != "" {
ing.Spec.IngressClassName = &spec.IngressClassName
}
return ing
}
// BuildPDB creates a PodDisruptionBudget with minAvailable=1.
func BuildPDB(name, ns string, selectorLabels map[string]string) *policyv1.PodDisruptionBudget {
minAvail := intstr.FromInt32(1)
labels := StandardLabels(name, "pdb", "", "")
return &policyv1.PodDisruptionBudget{
Name: name + "-pdb",
Namespace: ns,
Labels: labels,
Spec: policyv1.PodDisruptionBudgetSpec{
MinAvailable: &minAvail,
Selector: &metav1.LabelSelector{
MatchLabels: selectorLabels,
},
},
}
}
// buildServicePorts creates ServicePort entries from a NodeCluster spec.
func buildServicePorts(cluster *v1alpha1.NodeCluster) []corev1.ServicePort {
ports := []corev1.ServicePort{
{Name: "rpc", Port: portOrDefault(cluster.Spec.Ports.RPC, 8545), TargetPort: intstr.FromString("rpc"), Protocol: corev1.ProtocolTCP},
{Name: "p2p", Port: portOrDefault(cluster.Spec.Ports.P2P, 30303), TargetPort: intstr.FromString("p2p"), Protocol: corev1.ProtocolTCP},
{Name: "metrics", Port: portOrDefault(cluster.Spec.Ports.Metrics, 9090), TargetPort: intstr.FromString("metrics"), Protocol: corev1.ProtocolTCP},
}
for _, p := range cluster.Spec.Ports.Additional {
ports = append(ports, corev1.ServicePort{
Name: p.Name,
Port: p.Port,
TargetPort: intstr.FromString(p.Name),
Protocol: p.Protocol,
})
}
return ports
}
// buildContainerPorts creates ContainerPort entries from a NodeCluster spec and driver.
func buildContainerPorts(cluster *v1alpha1.NodeCluster, driver protocol.Driver) []corev1.ContainerPort {
defaults := driver.DefaultPorts()
ports := []corev1.ContainerPort{
{Name: "rpc", ContainerPort: portOrDefault(cluster.Spec.Ports.RPC, defaults.RPC), Protocol: corev1.ProtocolTCP},
{Name: "p2p", ContainerPort: portOrDefault(cluster.Spec.Ports.P2P, defaults.P2P), Protocol: corev1.ProtocolTCP},
{Name: "metrics", ContainerPort: portOrDefault(cluster.Spec.Ports.Metrics, defaults.Metrics), Protocol: corev1.ProtocolTCP},
}
for _, p := range cluster.Spec.Ports.Additional {
ports = append(ports, corev1.ContainerPort{
Name: p.Name,
ContainerPort: p.Port,
Protocol: p.Protocol,
})
}
// Add driver-default additional ports if not specified in cluster spec.
for _, dp := range defaults.Additional {
found := false
for _, p := range cluster.Spec.Ports.Additional {
if p.Name == dp.Name {
found = true
break
}
}
if !found {
ports = append(ports, corev1.ContainerPort{
Name: dp.Name,
ContainerPort: dp.Port,
Protocol: dp.Protocol,
})
}
}
return ports
}
// buildKrakenDConfig generates a simplified KrakenD JSON configuration from gateway routes.
func buildKrakenDConfig(gw *v1alpha1.Gateway) string {
// Build a minimal KrakenD configuration. In production this would be more
// elaborate, but this provides the skeleton for route configuration.
var endpoints strings.Builder
for i, route := range gw.Spec.Routes {
if i > 0 {
endpoints.WriteString(",")
}
endpoints.WriteString(fmt.Sprintf(`{"endpoint":"%s","backend":[{"url_pattern":"%s","host":["http://%s"]}]}`,
route.Prefix, route.Prefix, route.Backend))
}
return fmt.Sprintf(`{"version":3,"name":"%s","port":8080,"endpoints":[%s]}`, gw.Name, endpoints.String())
}
func tagOrDefault(tag string) string {
if tag == "" {
return "latest"
}
return tag
}
func portOrDefault(port, def int32) int32 {
if port > 0 {
return port
}
return def
}
func intStrPtr(val int32) *intstr.IntOrString {
v := intstr.FromInt32(val)
return &v
}
// --- Cloud (bootnode) manifest builders ---
// BuildCloudAPIDeployment creates a single Deployment for the Cloud API server
// that serves ALL brands. Brand routing is done at the API level by hostname.
func BuildCloudAPIDeployment(cloud *v1alpha1.Cloud) *appsv1.Deployment {
name := cloud.Name + "-api"
labels := StandardLabels(name, "cloud-api", cloud.Name, "")
labels = MergeLabels(labels, cloud.Spec.Labels)
selectorLbls := SelectorLabels(name)
image := "ghcr.io/hanzoai/bootnode:api-latest"
pullPolicy := corev1.PullIfNotPresent
if cloud.Spec.API.Image != nil {
image = fmt.Sprintf("%s:%s", cloud.Spec.API.Image.Repository, tagOrDefault(cloud.Spec.API.Image.Tag))
pullPolicy = cloud.Spec.API.Image.PullPolicy
}
replicas := derefInt32Ptr(cloud.Spec.API.Replicas, 3)
// Collect brand names for the BRANDS env var.
var brandNames []string
for _, brand := range cloud.Spec.Brands {
brandNames = append(brandNames, brand.Name)
}
// Build env vars: multi-tenant config + database credentials from secret + features.
env := []corev1.EnvVar{
{Name: "APP_ENV", Value: "production"},
{Name: "ENABLE_MULTI_TENANT", Value: "true"},
{Name: "BRANDS", Value: strings.Join(brandNames, ",")},
{Name: "FEATURE_WALLETS", Value: strconv.FormatBool(cloud.Spec.Features.Wallets)},
{Name: "FEATURE_BUNDLER", Value: strconv.FormatBool(cloud.Spec.Features.Bundler)},
{Name: "FEATURE_NFTS", Value: strconv.FormatBool(cloud.Spec.Features.NFTs)},
{Name: "FEATURE_GAS", Value: strconv.FormatBool(cloud.Spec.Features.Gas)},
{Name: "FEATURE_WEBHOOKS", Value: strconv.FormatBool(cloud.Spec.Features.Webhooks)},
{Name: "DATABASE_URL", ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
Name: cloud.Spec.Database.CredentialsSecret,
Key: "DATABASE_URL",
},
}},
{Name: "REDIS_URL", ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
Name: cloud.Spec.Database.CredentialsSecret,
Key: "REDIS_URL",
},
}},
}
// Add per-brand config: BRAND_{UPPER}_IAM_URL, BRAND_{UPPER}_IAM_ORG, etc.
for _, brand := range cloud.Spec.Brands {
prefix := "BRAND_" + strings.ToUpper(brand.Name) + "_"
env = append(env,
corev1.EnvVar{Name: prefix + "IAM_URL", Value: brand.IAM.URL},
corev1.EnvVar{Name: prefix + "IAM_ORG", Value: brand.IAM.Org},
corev1.EnvVar{Name: prefix + "IAM_CLIENT_ID", Value: brand.IAM.ClientID},
corev1.EnvVar{Name: prefix + "DOMAIN", Value: brand.Domain},
)
}
// Add cluster target env vars for NodeCluster routing.
for i, ct := range cloud.Spec.ClusterTargets {
prefix := fmt.Sprintf("CLUSTER_TARGET_%d_", i)
env = append(env,
corev1.EnvVar{Name: prefix + "BRAND", Value: ct.Brand},
corev1.EnvVar{Name: prefix + "NAMESPACE", Value: ct.Namespace},
)
}
env = append(env, cloud.Spec.Env...)
// Mount cluster target kubeconfigs as volumes.
var volumeMounts []corev1.VolumeMount
var volumes []corev1.Volume
for i, ct := range cloud.Spec.ClusterTargets {
volName := fmt.Sprintf("kubeconfig-%d", i)
mountPath := fmt.Sprintf("/etc/kubeconfig/%s", ct.Brand)
volumes = append(volumes, corev1.Volume{
Name: volName,
Secret: &corev1.SecretVolumeSource{
SecretName: ct.KubeconfigSecret,
},
})
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: volName,
MountPath: mountPath,
ReadOnly: true,
})
env = append(env, corev1.EnvVar{
Name: fmt.Sprintf("CLUSTER_TARGET_%d_KUBECONFIG", i),
Value: mountPath + "/kubeconfig",
})
}
container := corev1.Container{
Name: "api",
Image: image,
ImagePullPolicy: pullPolicy,
Env: env,
VolumeMounts: volumeMounts,
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 8000, Protocol: corev1.ProtocolTCP},
},
LivenessProbe: &corev1.Probe{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Port: intstr.FromInt32(8000),
},
InitialDelaySeconds: 15,
PeriodSeconds: 20,
TimeoutSeconds: 5,
FailureThreshold: 3,
},
ReadinessProbe: &corev1.Probe{
HTTPGet: &corev1.HTTPGetAction{
Path: "/health",
Port: intstr.FromInt32(8000),
},
InitialDelaySeconds: 5,
PeriodSeconds: 10,
TimeoutSeconds: 5,
FailureThreshold: 3,
},
}
if cloud.Spec.API.Resources != nil {
container.Resources = corev1.ResourceRequirements{
Requests: cloud.Spec.API.Resources.Requests,
Limits: cloud.Spec.API.Resources.Limits,
}
}
var pullSecrets []corev1.LocalObjectReference
for _, s := range cloud.Spec.ImagePullSecrets {
pullSecrets = append(pullSecrets, corev1.LocalObjectReference{Name: s})
}
terminationGrace := int64(30)
return &appsv1.Deployment{
Name: name,
Namespace: cloud.Namespace,
Labels: labels,
Annotations: cloud.Spec.Annotations,
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: selectorLbls},
Strategy: appsv1.DeploymentStrategy{
Type: appsv1.RollingUpdateDeploymentStrategyType,
RollingUpdate: &appsv1.RollingUpdateDeployment{
MaxSurge: intStrPtr(1),
MaxUnavailable: intStrPtr(0),
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: MergeLabels(labels, selectorLbls),
Annotations: cloud.Spec.Annotations,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{container},
Volumes: volumes,
ImagePullSecrets: pullSecrets,
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
}
// BuildCloudWebDeployment creates a Deployment for a single brand's web frontend.
func BuildCloudWebDeployment(cloud *v1alpha1.Cloud, brand v1alpha1.BrandConfig) *appsv1.Deployment {
name := cloud.Name + "-web-" + brand.Name
labels := StandardLabels(name, "cloud-web", cloud.Name, "")
labels = MergeLabels(labels, cloud.Spec.Labels)
labels["nchain.hanzo.ai/brand"] = brand.Name
selectorLbls := SelectorLabels(name)
image := "ghcr.io/hanzoai/bootnode:web-latest"
pullPolicy := corev1.PullIfNotPresent
if cloud.Spec.Web.Image != nil {
image = fmt.Sprintf("%s:%s", cloud.Spec.Web.Image.Repository, tagOrDefault(cloud.Spec.Web.Image.Tag))
pullPolicy = cloud.Spec.Web.Image.PullPolicy
}
replicas := derefInt32Ptr(cloud.Spec.Web.Replicas, 2)
apiURL := fmt.Sprintf("https://api.%s", brand.Domain)
env := []corev1.EnvVar{
{Name: "NODE_ENV", Value: "production"},
{Name: "NEXT_PUBLIC_BRAND", Value: brand.Name},
{Name: "NEXT_PUBLIC_API_URL", Value: apiURL},
{Name: "NEXT_PUBLIC_IAM_URL", Value: brand.IAM.URL},
{Name: "NEXT_PUBLIC_IAM_CLIENT_ID", Value: brand.IAM.ClientID},
}
env = append(env, cloud.Spec.Env...)
container := corev1.Container{
Name: "web",
Image: image,
ImagePullPolicy: pullPolicy,
Env: env,
Ports: []corev1.ContainerPort{
{Name: "http", ContainerPort: 3001, Protocol: corev1.ProtocolTCP},
},
LivenessProbe: &corev1.Probe{
HTTPGet: &corev1.HTTPGetAction{
Path: "/",
Port: intstr.FromInt32(3001),
},
InitialDelaySeconds: 10,
PeriodSeconds: 30,
TimeoutSeconds: 5,
FailureThreshold: 3,
},
ReadinessProbe: &corev1.Probe{
HTTPGet: &corev1.HTTPGetAction{
Path: "/",
Port: intstr.FromInt32(3001),
},
InitialDelaySeconds: 5,
PeriodSeconds: 10,
TimeoutSeconds: 5,
FailureThreshold: 3,
},
}
if cloud.Spec.Web.Resources != nil {
container.Resources = corev1.ResourceRequirements{
Requests: cloud.Spec.Web.Resources.Requests,
Limits: cloud.Spec.Web.Resources.Limits,
}
}
var pullSecrets []corev1.LocalObjectReference
for _, s := range cloud.Spec.ImagePullSecrets {
pullSecrets = append(pullSecrets, corev1.LocalObjectReference{Name: s})
}
terminationGrace := int64(30)
return &appsv1.Deployment{
Name: name,
Namespace: cloud.Namespace,
Labels: labels,
Annotations: cloud.Spec.Annotations,
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: selectorLbls},
Strategy: appsv1.DeploymentStrategy{
Type: appsv1.RollingUpdateDeploymentStrategyType,
RollingUpdate: &appsv1.RollingUpdateDeployment{
MaxSurge: intStrPtr(1),
MaxUnavailable: intStrPtr(0),
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: MergeLabels(labels, selectorLbls),
Annotations: cloud.Spec.Annotations,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{container},
ImagePullSecrets: pullSecrets,
TerminationGracePeriodSeconds: &terminationGrace,
},
},
},
}
}
// BuildCloudService creates a ClusterIP Service for a Cloud component.
func BuildCloudService(name, ns string, port int32, selectorLabels map[string]string) *corev1.Service {
labels := StandardLabels(name, "cloud", "", "")
return &corev1.Service{
Name: name,
Namespace: ns,
Labels: labels,
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: selectorLabels,
Ports: []corev1.ServicePort{
{Name: "http", Port: port, TargetPort: intstr.FromString("http"), Protocol: corev1.ProtocolTCP},
},
},
}
}
// BuildCloudHPA creates an HPA for a Cloud component.
func BuildCloudHPA(name, ns string, targetRef string, spec *v1alpha1.AutoscalingSpec) *autoscalingv2.HorizontalPodAutoscaler {
labels := StandardLabels(name+"-hpa", "cloud-hpa", "", "")
minReplicas := derefInt32Ptr(spec.MinReplicas, 2)
maxReplicas := derefInt32Ptr(spec.MaxReplicas, 20)
var metrics []autoscalingv2.MetricSpec
if spec.TargetCPU != nil {
metrics = append(metrics, autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceCPU,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: spec.TargetCPU,
},
},
})
}
if spec.TargetMemory != nil {
metrics = append(metrics, autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceMemory,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: spec.TargetMemory,
},
},
})
}
// Aggressive scale-up, conservative scale-down.
scaleUp := int32(60) // 60s stabilization for scale-up
scaleDown := int32(300) // 5min stabilization for scale-down
return &autoscalingv2.HorizontalPodAutoscaler{
Name: name + "-hpa",
Namespace: ns,
Labels: labels,
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
APIVersion: "apps/v1",
Kind: "Deployment",
Name: targetRef,
},
MinReplicas: &minReplicas,
MaxReplicas: maxReplicas,
Metrics: metrics,
Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleUp: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &scaleUp,
},
ScaleDown: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: &scaleDown,
},
},
},
}
}
// BuildCloudAPIIngress creates a single Ingress for ALL brands' API and WebSocket
// domains. All api.{domain} and ws.{domain} hosts point to the shared API service.
func BuildCloudAPIIngress(cloud *v1alpha1.Cloud) *networkingv1.Ingress {
name := cloud.Name + "-api"
labels := StandardLabels(name, "cloud-api-ingress", cloud.Name, "")
pathType := networkingv1.PathTypePrefix
apiSvcName := cloud.Name + "-api"
annotations := map[string]string{}
if cloud.Spec.Ingress != nil {
if cloud.Spec.Ingress.TLS {
clusterIssuer := cloud.Spec.Ingress.ClusterIssuer
if clusterIssuer == "" {
clusterIssuer = "letsencrypt-prod"
}
annotations["cert-manager.io/cluster-issuer"] = clusterIssuer
}
maps.Copy(annotations, cloud.Spec.Ingress.Annotations)
}
var rules []networkingv1.IngressRule
var tls []networkingv1.IngressTLS
tlsEnabled := cloud.Spec.Ingress != nil && cloud.Spec.Ingress.TLS
for _, brand := range cloud.Spec.Brands {
apiHost := "api." + brand.Domain
wsHost := "ws." + brand.Domain
// ONE CERTIFICATE PER BRAND, never one bundle for all of them.
//
// ACME issues a SAN certificate only when EVERY name on it validates. A single
// bundle therefore fails completely whenever one brand cannot answer its HTTP-01
// challenge here — which is the normal case for a brand fronted by a CDN that
// terminates TLS at its own edge and proxies to a different origin.
//
// Observed on the web3-platform Cloud: bootno.de, api.bootno.de and ws.bootno.de
// are CDN-fronted and 404 the challenge, so `web3-platform-api-tls` sat stuck on
// `Fields on existing CertificateRequest resource not up to date` past its
// 2026-07-09 renewal time and was heading for expiry on 2026-08-08 — taking the
// eight healthy web3.* hosts, which all validate fine, down with it.
//
// Per-brand entries make failure brand-local: a brand that cannot validate loses
// only its own certificate. This also matches BuildCloudWebIngress below, which
// has always been per-brand.
if tlsEnabled {
tls = append(tls, networkingv1.IngressTLS{
Hosts: []string{apiHost, wsHost},
SecretName: name + "-" + brand.Name + "-tls",
})
}
for _, host := range []string{apiHost, wsHost} {
rules = append(rules, networkingv1.IngressRule{
Host: host,
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{{
Path: "/",
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: apiSvcName,
Port: networkingv1.ServiceBackendPort{Number: 8000},
},
},
}},
},
})
}
}
ing := &networkingv1.Ingress{
Name: name,
Namespace: cloud.Namespace,
Labels: labels,
Annotations: annotations,
Spec: networkingv1.IngressSpec{
Rules: rules,
TLS: tls,
},
}
if cloud.Spec.Ingress != nil && cloud.Spec.Ingress.IngressClassName != "" {
ing.Spec.IngressClassName = &cloud.Spec.Ingress.IngressClassName
}
return ing
}
// BuildCloudWebIngress creates an Ingress for a single brand's web frontend.
// Only the web domain (brand.Domain) routes to that brand's web service.
func BuildCloudWebIngress(cloud *v1alpha1.Cloud, brand v1alpha1.BrandConfig) *networkingv1.Ingress {
name := cloud.Name + "-web-" + brand.Name
labels := StandardLabels(name, "cloud-web-ingress", cloud.Name, "")
labels["nchain.hanzo.ai/brand"] = brand.Name
pathType := networkingv1.PathTypePrefix
webSvcName := cloud.Name + "-web-" + brand.Name
annotations := map[string]string{}
if cloud.Spec.Ingress != nil {
if cloud.Spec.Ingress.TLS {
clusterIssuer := cloud.Spec.Ingress.ClusterIssuer
if clusterIssuer == "" {
clusterIssuer = "letsencrypt-prod"
}
annotations["cert-manager.io/cluster-issuer"] = clusterIssuer
}
maps.Copy(annotations, cloud.Spec.Ingress.Annotations)
}
webHost := brand.Domain
rules := []networkingv1.IngressRule{
{
Host: webHost,
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{{
Path: "/",
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: webSvcName,
Port: networkingv1.ServiceBackendPort{Number: 3001},
},
},
}},
},
},
}
var tls []networkingv1.IngressTLS
if cloud.Spec.Ingress != nil && cloud.Spec.Ingress.TLS {
tls = append(tls, networkingv1.IngressTLS{
Hosts: []string{webHost},
SecretName: name + "-tls",
})
}
ing := &networkingv1.Ingress{
Name: name,
Namespace: cloud.Namespace,
Labels: labels,
Annotations: annotations,
Spec: networkingv1.IngressSpec{
Rules: rules,
TLS: tls,
},
}
if cloud.Spec.Ingress != nil && cloud.Spec.Ingress.IngressClassName != "" {
ing.Spec.IngressClassName = &cloud.Spec.Ingress.IngressClassName
}
return ing
}
func derefInt32Ptr(p *int32, def int32) int32 {
if p != nil {
return *p
}
return def
}
// tlsPerRegisteredDomain groups hosts into one IngressTLS entry per registered
// domain, so each domain gets its own certificate.
//
// ACME issues a SAN certificate only when EVERY name on it validates. Putting
// hosts from different domains on one certificate therefore couples them: a
// single name that cannot answer its challenge — typically one fronted by a CDN
// that terminates TLS at its own edge — blocks issuance and renewal for all the
// others.
//
// That is not theoretical. `web3-platform-api-tls` bundled ten hosts across five
// domains; bootno.de could not validate, and the certificate sat stuck past its
// renewal time heading for expiry, taking eight healthy hosts with it. Grouping
// keeps a failure inside the domain that caused it.
//
// The common single-domain case is unchanged: one entry, secret "<name>-tls".
func tlsPerRegisteredDomain(name string, hosts []string) []networkingv1.IngressTLS {
byDomain := map[string][]string{}
var order []string
for _, h := range hosts {
d := registeredDomain(h)
if _, seen := byDomain[d]; !seen {
order = append(order, d)
}
byDomain[d] = append(byDomain[d], h)
}
tls := make([]networkingv1.IngressTLS, 0, len(order))
for _, d := range order {
secret := name + "-tls"
if len(order) > 1 {
// Only qualify the name when it must be unique, so existing
// single-domain Ingresses keep the secret they already reference.
secret = name + "-" + strings.ReplaceAll(d, ".", "-") + "-tls"
}
tls = append(tls, networkingv1.IngressTLS{Hosts: byDomain[d], SecretName: secret})
}
return tls
}
// registeredDomain returns the last two labels of a hostname ("api.web3.lux.network"
// -> "lux.network"). Deliberately simple: it groups certificates, and a
// multi-label public suffix such as "co.uk" only means two of those domains share
// one certificate — the pre-existing behaviour, never a broken one.
func registeredDomain(host string) string {
parts := strings.Split(strings.TrimSuffix(host, "."), ".")
if len(parts) < 2 {
return host
}
return strings.Join(parts[len(parts)-2:], ".")
}