mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
307 lines
8.9 KiB
Go
307 lines
8.9 KiB
Go
package deploy
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
"text/template"
|
|
)
|
|
|
|
// Tenant is the org a product is being provisioned for, plus the deploy knobs
|
|
// the control plane chooses. The defaults make a minimal, safe deployment.
|
|
type Tenant struct {
|
|
// Org is the tenant org slug (e.g. "acme"). It scopes the CR name, the PVC,
|
|
// the ingress host, and — crucially — the COMMERCE_SERVICE_ORG the meter
|
|
// stamps as X-Org-Id so usage debits the right org's prepaid balance.
|
|
// (X-Org-Id, not X-Hanzo-Org: commerce reads the latter nowhere, so it
|
|
// routes nothing and a debit lands on the fallback org instead.)
|
|
Org string
|
|
|
|
// Namespace the CR is applied to. Empty defaults to "hanzo" (shared
|
|
// namespace, name-suffixed isolation). Set to a per-org namespace for
|
|
// strong isolation.
|
|
Namespace string
|
|
|
|
// Tag pins the product image (immutable semver/sha). Required — floating
|
|
// tags must never reach a cluster.
|
|
Tag string
|
|
|
|
// MeterProxyImage is the GHCR image of the metering sidecar. Required when
|
|
// the product is Meterable.
|
|
MeterProxyImage string
|
|
|
|
// MeterProxyTag pins the sidecar image.
|
|
MeterProxyTag string
|
|
|
|
// CommerceURL overrides the in-cluster commerce address (rarely needed).
|
|
CommerceURL string
|
|
|
|
// CommerceSecret is the k8s Secret holding the commerce service token under
|
|
// key "commerceToken" (KMS-backed). The sidecar reads it via secretKeyRef —
|
|
// the token is NEVER inlined. Defaults to "cloud-api-secrets".
|
|
CommerceSecret string
|
|
|
|
// IngressHost is the public host (e.g. "acme.vector.hanzo.ai"). Empty =
|
|
// no ingress (ClusterIP only; reached via the gateway).
|
|
IngressHost string
|
|
|
|
// Replicas defaults to 1.
|
|
Replicas int
|
|
}
|
|
|
|
func (t Tenant) namespace() string {
|
|
if t.Namespace != "" {
|
|
return t.Namespace
|
|
}
|
|
return "hanzo"
|
|
}
|
|
|
|
func (t Tenant) commerceSecret() string {
|
|
if t.CommerceSecret != "" {
|
|
return t.CommerceSecret
|
|
}
|
|
return "cloud-api-secrets"
|
|
}
|
|
|
|
func (t Tenant) replicas() int {
|
|
if t.Replicas > 0 {
|
|
return t.Replicas
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// Render produces the operator Service CR YAML that deploys product p for
|
|
// tenant t with prepaid metering wired. The shape:
|
|
//
|
|
// - For a Meterable product: the Service's public port is the meter-proxy
|
|
// (METER_PROXY_LISTEN), which gates on balance then forwards to the product
|
|
// on 127.0.0.1:<product port> in the SAME pod (a sidecar). The product
|
|
// never sees an unmetered request.
|
|
// - For a non-Meterable product (billing in-process, e.g. base): the product
|
|
// container is exposed directly; the commerce env is still injected so the
|
|
// in-process meter can bill.
|
|
//
|
|
// Every Meterable deployment carries the commerce wiring as env: COMMERCE_URL,
|
|
// the service token via secretKeyRef (KMS-backed, never inlined), and
|
|
// COMMERCE_SERVICE_ORG=<tenant org> so debits land in the tenant's namespace.
|
|
func Render(p Product, t Tenant) (string, error) {
|
|
if strings.TrimSpace(t.Org) == "" {
|
|
return "", fmt.Errorf("deploy: tenant Org is required")
|
|
}
|
|
if strings.TrimSpace(t.Tag) == "" {
|
|
return "", fmt.Errorf("deploy: tenant Tag is required (no floating tags)")
|
|
}
|
|
if p.Meterable && (strings.TrimSpace(t.MeterProxyImage) == "" || strings.TrimSpace(t.MeterProxyTag) == "") {
|
|
return "", fmt.Errorf("deploy: MeterProxyImage and MeterProxyTag are required for meterable product %q", p.Name)
|
|
}
|
|
if p.StorageMount != "" && p.StorageSize == "" {
|
|
return "", fmt.Errorf("deploy: product %q sets StorageMount but no StorageSize", p.Name)
|
|
}
|
|
|
|
commerceURL := t.CommerceURL
|
|
if commerceURL == "" {
|
|
commerceURL = "http://commerce.hanzo.svc.cluster.local:8001"
|
|
}
|
|
|
|
data := tmplData{
|
|
Name: fmt.Sprintf("%s-%s", p.Name, t.Org),
|
|
Namespace: t.namespace(),
|
|
Org: t.Org,
|
|
Product: p.Name,
|
|
Provider: p.provider(),
|
|
Unit: string(p.Unit),
|
|
ProductImage: p.Image,
|
|
ProductTag: t.Tag,
|
|
ProductPort: p.Port,
|
|
Replicas: t.replicas(),
|
|
Meterable: p.Meterable,
|
|
ProxyImage: t.MeterProxyImage,
|
|
ProxyTag: t.MeterProxyTag,
|
|
ProxyListen: p.Port + 10000, // public port = product port + 10000
|
|
Prices: p.Prices,
|
|
SkipPaths: strings.Join(p.SkipPaths, ","),
|
|
CommerceURL: commerceURL,
|
|
CommerceSecret: t.commerceSecret(),
|
|
StorageMount: p.StorageMount,
|
|
StorageSize: p.StorageSize,
|
|
PVCName: fmt.Sprintf("%s-%s-data", p.Name, t.Org),
|
|
IngressHost: t.IngressHost,
|
|
Env: sortedEnv(p.Env),
|
|
}
|
|
// The public service port: proxy when metered, else the product itself.
|
|
data.ServicePort = data.ProductPort
|
|
if p.Meterable {
|
|
data.ServicePort = data.ProxyListen
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := crTemplate.Execute(&buf, data); err != nil {
|
|
return "", fmt.Errorf("deploy: render %q for %q: %w", p.Name, t.Org, err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
type kv struct{ Key, Value string }
|
|
|
|
func sortedEnv(m map[string]string) []kv {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
// simple insertion sort to avoid importing sort for a tiny map; keeps output
|
|
// deterministic so the same input always yields byte-identical YAML.
|
|
for i := 1; i < len(keys); i++ {
|
|
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
|
|
keys[j-1], keys[j] = keys[j], keys[j-1]
|
|
}
|
|
}
|
|
out := make([]kv, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, kv{Key: k, Value: m[k]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
type tmplData struct {
|
|
Name string
|
|
Namespace string
|
|
Org string
|
|
Product string
|
|
Provider string
|
|
Unit string
|
|
ProductImage string
|
|
ProductTag string
|
|
ProductPort int
|
|
ServicePort int
|
|
Replicas int
|
|
Meterable bool
|
|
ProxyImage string
|
|
ProxyTag string
|
|
ProxyListen int
|
|
Prices string
|
|
SkipPaths string
|
|
CommerceURL string
|
|
CommerceSecret string
|
|
StorageMount string
|
|
StorageSize string
|
|
PVCName string
|
|
IngressHost string
|
|
Env []kv
|
|
}
|
|
|
|
// crTemplate emits a hanzo.ai/v1 Service CR. When Meterable, the meter-proxy is
|
|
// the main container (terminates ServicePort) with the product as a sidecar on
|
|
// localhost; the proxy holds all commerce wiring. When not Meterable, the
|
|
// product is the main container and carries the commerce env itself.
|
|
var crTemplate = template.Must(template.New("cr").Parse(`# Generated by github.com/hanzoai/commerce/metering/deploy — do not edit by hand.
|
|
# Product: {{.Product}} Tenant org: {{.Org}} Billed per: {{.Unit}}
|
|
apiVersion: hanzo.ai/v1
|
|
kind: Service
|
|
metadata:
|
|
name: {{.Name}}
|
|
namespace: {{.Namespace}}
|
|
labels:
|
|
app.kubernetes.io/part-of: paas
|
|
app.kubernetes.io/component: {{.Product}}
|
|
hanzo.ai/tenant: {{.Org}}
|
|
hanzo.ai/metered: "{{.Meterable}}"
|
|
spec:
|
|
{{- if .Meterable}}
|
|
image:
|
|
repository: {{.ProxyImage}}
|
|
tag: "{{.ProxyTag}}"
|
|
pullPolicy: IfNotPresent
|
|
replicas: {{.Replicas}}
|
|
ports:
|
|
- name: http
|
|
containerPort: {{.ProxyListen}}
|
|
servicePort: {{.ServicePort}}
|
|
env:
|
|
- name: METER_PROXY_LISTEN
|
|
value: ":{{.ProxyListen}}"
|
|
- name: METER_PROXY_UPSTREAM
|
|
value: "http://127.0.0.1:{{.ProductPort}}"
|
|
- name: METER_PROXY_PROVIDER
|
|
value: "{{.Provider}}"
|
|
- name: METER_PROXY_PRICES
|
|
value: "{{.Prices}}"
|
|
- name: METER_PROXY_SKIP
|
|
value: "{{.SkipPaths}}"
|
|
- name: COMMERCE_URL
|
|
value: "{{.CommerceURL}}"
|
|
- name: COMMERCE_SERVICE_ORG
|
|
value: "{{.Org}}"
|
|
- name: COMMERCE_SERVICE_TOKEN
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{.CommerceSecret}}
|
|
key: commerceToken
|
|
sidecars:
|
|
# The product runs as a sidecar; containers share the pod network namespace,
|
|
# so the proxy reaches it on 127.0.0.1:{{.ProductPort}} with no port mapping
|
|
# needed. (The operator Container schema has no ports field — only the main
|
|
# container's ports become the Service.)
|
|
- name: {{.Product}}
|
|
image: {{.ProductImage}}:{{.ProductTag}}
|
|
{{- if .Env}}
|
|
env:
|
|
{{- range .Env}}
|
|
- name: {{.Key}}
|
|
value: "{{.Value}}"
|
|
{{- end}}
|
|
{{- end}}
|
|
{{- if .StorageMount}}
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: {{.StorageMount}}
|
|
{{- end}}
|
|
{{- else}}
|
|
image:
|
|
repository: {{.ProductImage}}
|
|
tag: "{{.ProductTag}}"
|
|
pullPolicy: IfNotPresent
|
|
replicas: {{.Replicas}}
|
|
ports:
|
|
- name: http
|
|
containerPort: {{.ProductPort}}
|
|
servicePort: {{.ServicePort}}
|
|
env:
|
|
- name: COMMERCE_URL
|
|
value: "{{.CommerceURL}}"
|
|
- name: COMMERCE_SERVICE_ORG
|
|
value: "{{.Org}}"
|
|
- name: COMMERCE_SERVICE_TOKEN
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{.CommerceSecret}}
|
|
key: commerceToken
|
|
{{- range .Env}}
|
|
- name: {{.Key}}
|
|
value: "{{.Value}}"
|
|
{{- end}}
|
|
{{- if .StorageMount}}
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: {{.StorageMount}}
|
|
{{- end}}
|
|
{{- end}}
|
|
{{- if .StorageMount}}
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim:
|
|
claimName: {{.PVCName}}
|
|
{{- end}}
|
|
imagePullSecrets:
|
|
- name: ghcr-secret
|
|
{{- if .IngressHost}}
|
|
ingress:
|
|
enabled: true
|
|
hosts:
|
|
- {{.IngressHost}}
|
|
tls: true
|
|
{{- else}}
|
|
ingress:
|
|
enabled: false
|
|
{{- end}}
|
|
`))
|