Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 135 additions & 3 deletions cmd/internal/schemagen/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
// schemagen generates JSON Schema files into pkg/provision/schema/:
// one per provisioner config struct, plus a portable common.schema.json
// reflected from CommonConfig alone.
// schemagen generates JSON Schema files for two distinct surfaces:
//
// - Provision-config schemas under pkg/provision/schema/: one
// per provisioner config struct (qemu, docker, multipass) plus
// a portable common.schema.json reflected from CommonConfig.
//
// - Output schemas alongside the Go type that produces them
// (e.g. pkg/gateway/state.schema.json next to gateway.State).
// These document the JSON shape published by `y-cluster`
// subcommands so downstream consumers can validate / parse
// against a stable contract.
//
// Each per-provider schema has its `provider` property post-processed
// from the inherited enum into a single-value `const` so the file
Expand Down Expand Up @@ -34,6 +42,7 @@ import (
"github.com/invopop/jsonschema"
"sigs.k8s.io/yaml"

"github.com/Yolean/y-cluster/pkg/gateway"
"github.com/Yolean/y-cluster/pkg/provision/config"
)

Expand Down Expand Up @@ -102,9 +111,132 @@ func run() error {
}
fmt.Printf("wrote %s\n", commonOut)

// Output schemas: not provider-config schemas, but other
// stable JSON shapes y-cluster produces for downstream
// consumption. These live alongside the Go type that
// produces them, NOT under pkg/provision/schema/ (which is
// for input/config schemas). Add new outputs below as more
// y-cluster commands publish stable JSON contracts.
gatewayStateOut := filepath.Join(root, "pkg", "gateway", "state.schema.json")
// schemaVersion is a top-level field on State; pin it to
// the SOURCE version constant via an enum-of-one on the
// generated schema. A future SchemaVersion bump means
// updating that constant + this enum in lockstep; old
// snapshots would then validate against the previous
// version of the schema doc (which can be served from a
// versioned URL once we need it -- the canonical URL stays
// unversioned).
if err := writeOutputSchema(gatewayStateOut, &gateway.State{}, gateway.SchemaID,
enumPin{DefName: "State", PropName: "schemaVersion", Values: []string{gateway.SchemaVersion}},
); err != nil {
return fmt.Errorf("generate %s: %w", gatewayStateOut, err)
}
fmt.Printf("wrote %s\n", gatewayStateOut)

return nil
}

// enumPin is one (definition, property, values) tuple for the
// schemagen output-schema post-processor. Use it to constrain a
// reflected property to a fixed enum (typically a single-value
// enum representing a schema-version stamp).
type enumPin struct {
DefName string
PropName string
Values []string
}

// writeOutputSchema reflects a non-provider Go struct into a
// standalone JSON Schema file. Differs from writeProviderSchema
// in two ways:
//
// - Uses the `json` struct tag for property names, since the
// output is JSON (not YAML), and consumers parse the JSON
// directly. Reusing FieldNameTag="yaml" would produce YAML-
// tagged property names that don't match the runtime output.
// - No provider-narrowing post-processing -- the schema covers
// the full output type as-is.
//
// The schemaID is written into the schema's $id so consumers
// can validate by URL reference. SchemaID values live in the
// source package as exported constants (e.g. gateway.SchemaID).
//
// enumPins post-process the reflected schema to constrain
// specific properties to a fixed enum -- used today for
// schema-version stamping (single-value enum so consumers
// validate the snapshot's declared version against the schema
// they hold).
func writeOutputSchema(outPath string, sample any, schemaID string, enumPins ...enumPin) error {
r := &jsonschema.Reflector{
AllowAdditionalProperties: false,
DoNotReference: false,
FieldNameTag: "json",
// Keep RequiredFromJSONSchemaTags symmetric with the
// provider schemas: omitempty fields fall through to
// non-required without us having to hand-tag each one.
RequiredFromJSONSchemaTags: false,
}
schema := r.Reflect(sample)
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
}
// Replace the reflector's auto-generated $id with our stable
// one. invopop emits a github.com/-prefixed URL by default;
// we want the schema reachable by a URL operators control.
data, err = setSchemaID(data, schemaID)
if err != nil {
return err
}
for _, pin := range enumPins {
data, err = injectFieldEnum(data, pin.DefName, pin.PropName, pin.Values)
if err != nil {
return fmt.Errorf("inject enum on %s.%s: %w", pin.DefName, pin.PropName, err)
}
}
return os.WriteFile(outPath, append(data, '\n'), 0o644)
}

// injectFieldEnum sets `enum` on the named property of the named
// definition under $defs. Errors when the definition or property
// can't be found -- a typo there is a real bug in the generator
// wiring, not a runtime data shape question.
func injectFieldEnum(data []byte, defName, propName string, values []string) ([]byte, error) {
var doc map[string]any
if err := json.Unmarshal(data, &doc); err != nil {
return nil, err
}
defs, ok := doc["$defs"].(map[string]any)
if !ok {
return nil, fmt.Errorf("$defs missing or wrong type")
}
def, ok := defs[defName].(map[string]any)
if !ok {
return nil, fmt.Errorf("definition %q missing under $defs", defName)
}
props, ok := def["properties"].(map[string]any)
if !ok {
return nil, fmt.Errorf("properties missing on %s", defName)
}
prop, ok := props[propName].(map[string]any)
if !ok {
return nil, fmt.Errorf("property %q missing on %s", propName, defName)
}
prop["enum"] = stringSliceToAny(values)
return json.MarshalIndent(doc, "", " ")
}

// setSchemaID rewrites the top-level $id field of the schema
// document. Returns the raw JSON bytes with the new $id.
func setSchemaID(data []byte, schemaID string) ([]byte, error) {
var doc map[string]any
if err := json.Unmarshal(data, &doc); err != nil {
return nil, err
}
doc["$id"] = schemaID
return json.MarshalIndent(doc, "", " ")
}

// checkCollisions ensures no two providers declare the same own
// (non-embedded) yaml field name. CommonConfig fields are skipped:
// they're shared by design and surface in every provider via
Expand Down
145 changes: 145 additions & 0 deletions cmd/y-cluster/gateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"encoding/json"
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/Yolean/y-cluster/pkg/cluster"
"github.com/Yolean/y-cluster/pkg/gateway"
)

// gatewayCmd is the parent of `y-cluster gateway *`. Today
// it has one child (`state`); `clear-dns-hint-ip` is wired
// here too because it lives in the same surface area, but
// the canonical caller is prepare-export, not interactive.
//
// Future ops (rotate-cert, diff-vs-baseline, route-test) slot
// under this same command group when use cases land.
func gatewayCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "gateway",
Short: "Inspect and manage the y-cluster Gateway state",
}
cmd.AddCommand(gatewayStateCmd())
cmd.AddCommand(gatewayHostnamesCmd())
cmd.AddCommand(gatewayClearDNSHintIPCmd())
return cmd
}

// gatewayHostnamesCmd extracts a deduped, sorted list of the
// non-wildcard hostnames the cluster's HTTPRoutes / GRPCRoutes
// declare, derived from the same `gateway.Fetch` snapshot the
// `state` subcommand emits.
//
// The canonical consumer is the appliance build script's TLS LB
// stage: `TLS_DOMAINS=$(y-cluster gateway hostnames --csv)`
// makes the LB cert's SAN list match exactly what the cluster
// serves, eliminating drift between the operator's env var and
// the cluster's HTTPRoute manifests.
//
// Default output is one hostname per line (works with `xargs`,
// `mapfile`, `read`); --csv joins with `,` for the SAN-list
// shape `do_tls_frontend` expects.
func gatewayHostnamesCmd() *cobra.Command {
var contextName string
var csv bool
cmd := &cobra.Command{
Use: "hostnames",
Short: "Print non-wildcard hostnames from the cluster's gateway state",
Long: `Reads ` + "`gateway state`" + ` and projects unique non-wildcard hostnames
from .summary.listeners[].hosts[].hostname. Default output is one
hostname per line, sorted; --csv joins with "," for the format
TLS_DOMAINS / do_tls_frontend consume.

Use case: derive an LB cert's SAN list directly from the cluster's
routing plane so the cert and the routes can't drift.

TLS_DOMAINS=$(y-cluster gateway hostnames --context=local --csv)`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
st, err := gateway.Fetch(c.Context(), contextName)
if err != nil {
return err
}
hosts := gateway.Hostnames(st)
out := c.OutOrStdout()
if csv {
fmt.Fprintln(out, strings.Join(hosts, ","))
return nil
}
for _, h := range hosts {
fmt.Fprintln(out, h)
}
return nil
},
}
cmd.Flags().StringVar(&contextName, "context", cluster.DefaultContext, "kubeconfig context name")
cmd.Flags().BoolVar(&csv, "csv", false, "join hostnames with comma instead of newline")
return cmd
}

func gatewayStateCmd() *cobra.Command {
var contextName string
cmd := &cobra.Command{
Use: "state",
Short: "Print the cluster's reconciled Gateway state as JSON",
Long: `Snapshot the cluster's GatewayClass, Gateway, HTTPRoute, GRPCRoute,
ClientTrafficPolicy, and BackendTrafficPolicy resources -- including
their reconciliation status conditions -- and print as JSON to stdout.

The shape is documented in pkg/gateway/state.schema.json
(generated). Consumers parse the JSON to determine HTTPS readiness
(walk gateways[].status.listeners[]), redirect-vs-real-traffic on a
port (walk httpRoutes[].rules), and policy effects (walk
clientTrafficPolicies[].spec for numTrustedHops / trustedCIDRs +
.status for whether envoy-gateway accepted them).

Used by:
- prepare-export: dumps to <cacheDir>/<name>-gateway-state.json so
the appliance bundle ships the snapshot the customer received.
- Operator interactive use: ` + "`y-cluster gateway state | jq ...`" + ` for
debugging.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
st, err := gateway.Fetch(c.Context(), contextName)
if err != nil {
return err
}
out, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
fmt.Fprintln(c.OutOrStdout(), string(out))
return nil
},
}
cmd.Flags().StringVar(&contextName, "context", cluster.DefaultContext, "kubeconfig context name")
return cmd
}

func gatewayClearDNSHintIPCmd() *cobra.Command {
var contextName string
var gatewayClassName string
cmd := &cobra.Command{
Use: "clear-dns-hint-ip",
Short: "Remove the yolean.se/dns-hint-ip annotation from the y-cluster GatewayClass",
Long: `Used by prepare-export to strip the per-deploy IP from the appliance
snapshot. The annotation is set by envoygateway.Install at provision
time, scoped to the operator's local LB or public IP -- baking it
into the customer-facing snapshot would point their tooling at our
infrastructure.

Idempotent: a no-op when the annotation isn't present (or the
GatewayClass doesn't exist).`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
return gateway.ClearDNSHintIPAnnotation(c.Context(), contextName, gatewayClassName)
},
}
cmd.Flags().StringVar(&contextName, "context", cluster.DefaultContext, "kubeconfig context name")
cmd.Flags().StringVar(&gatewayClassName, "gateway-class", "y-cluster", "GatewayClass name to patch")
return cmd
}
1 change: 1 addition & 0 deletions cmd/y-cluster/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ func rootCmd() *cobra.Command {
root.AddCommand(crictlCmd())
root.AddCommand(cacheCmd())
root.AddCommand(echoCmd())
root.AddCommand(gatewayCmd())
root.AddCommand(localstorageCmd())

return root
Expand Down
Loading