mirror of
https://github.com/outbackdingo/cozystack.git
synced 2026-01-27 18:18:41 +00:00
This commit patches the Cozystack API server to tolerate an absence of Cozystack Resource Definitions either registered as CRDs on the k8s API or simply as an absence of CozyRDs persisted to etcd. This decouples the upgrade of the CozyRD CRD from the upgrade of the Cozystack API. ```release-note [api,platform] Decouple the Cozystack API from the Cozystack Resource Definitions, allowing independent upgrades of either one and a more reliable migration from 0.36 to 0.37. ``` Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
396 lines
11 KiB
Go
396 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"k8s.io/kube-openapi/pkg/spec3"
|
|
"k8s.io/kube-openapi/pkg/validation/spec"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// shared helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const (
|
|
apiPrefix = "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1"
|
|
baseRef = apiPrefix + ".Application"
|
|
baseListRef = apiPrefix + ".ApplicationList"
|
|
baseStatusRef = apiPrefix + ".ApplicationStatus"
|
|
smp = "application/strategic-merge-patch+json"
|
|
)
|
|
|
|
// deepCopySchema clones *spec.Schema via JSON-marshal/unmarshal.
|
|
func deepCopySchema(in *spec.Schema) *spec.Schema {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(in)
|
|
if err != nil {
|
|
panic(fmt.Errorf("failed to marshal schema: %w", err))
|
|
}
|
|
var out spec.Schema
|
|
err = json.Unmarshal(raw, &out)
|
|
if err != nil {
|
|
panic(fmt.Errorf("failed to unmarshal schema: %w", err))
|
|
}
|
|
return &out
|
|
}
|
|
|
|
// findSpecContainer returns first object owning ".spec".
|
|
func findSpecContainer(s *spec.Schema) *spec.Schema {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if len(s.Type) > 0 && s.Type.Contains("object") {
|
|
if _, ok := s.Properties["spec"]; ok {
|
|
return s
|
|
}
|
|
}
|
|
for _, branch := range [][]spec.Schema{s.AllOf, s.OneOf, s.AnyOf} {
|
|
for i := range branch {
|
|
if res := findSpecContainer(&branch[i]); res != nil {
|
|
return res
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// patchSpec injects/overrides ".spec" with user JSON (or schemaless object).
|
|
func patchSpec(target *spec.Schema, raw string) error {
|
|
if strings.TrimSpace(raw) == "" {
|
|
if target.Properties == nil {
|
|
target.Properties = map[string]spec.Schema{}
|
|
}
|
|
prop := target.Properties["spec"]
|
|
prop.AdditionalProperties = &spec.SchemaOrBool{Allows: true}
|
|
target.Properties["spec"] = prop
|
|
return nil
|
|
}
|
|
|
|
var custom spec.Schema
|
|
if err := json.Unmarshal([]byte(raw), &custom); err != nil {
|
|
return err
|
|
}
|
|
if custom.AdditionalProperties == nil {
|
|
custom.AdditionalProperties = &spec.SchemaOrBool{Allows: true}
|
|
}
|
|
if target.Properties == nil {
|
|
target.Properties = map[string]spec.Schema{}
|
|
}
|
|
target.Properties["spec"] = custom
|
|
return nil
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
/* DRY helpers */
|
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
|
|
// cloneKindSchemas: from base schemas, create new schemas for a specific kind.
|
|
func cloneKindSchemas(kind string, base, baseStatus, baseList *spec.Schema, v3 bool) (obj, status, list *spec.Schema) {
|
|
obj = deepCopySchema(base)
|
|
status = deepCopySchema(baseStatus)
|
|
list = deepCopySchema(baseList)
|
|
|
|
// Ensure we have valid clones
|
|
if obj == nil || status == nil || list == nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// GVK-extensions
|
|
setGVK := func(s *spec.Schema, k string) {
|
|
s.Extensions = map[string]interface{}{
|
|
"x-kubernetes-group-version-kind": []interface{}{
|
|
map[string]interface{}{"group": "apps.cozystack.io", "version": "v1alpha1", "kind": k},
|
|
},
|
|
}
|
|
}
|
|
setGVK(obj, kind)
|
|
setGVK(list, kind+"List")
|
|
|
|
// fix refs
|
|
refPrefix := "#/components/schemas/" // v3
|
|
if !v3 {
|
|
refPrefix = "#/definitions/"
|
|
}
|
|
statusRef := refPrefix + apiPrefix + "." + kind + "Status"
|
|
itemRef := refPrefix + apiPrefix + "." + kind
|
|
|
|
if prop, ok := obj.Properties["status"]; ok {
|
|
prop.Ref = spec.MustCreateRef(statusRef)
|
|
obj.Properties["status"] = prop
|
|
}
|
|
if list.Properties != nil {
|
|
if items := list.Properties["items"]; items.Items != nil && items.Items.Schema != nil {
|
|
items.Items.Schema.Ref = spec.MustCreateRef(itemRef)
|
|
list.Properties["items"] = items
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// rewriteDocRefs rewrites all $ref in the OpenAPI document
|
|
func rewriteDocRefs(doc interface{}) ([]byte, error) {
|
|
raw, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal OpenAPI document: %w", err)
|
|
}
|
|
var any interface{}
|
|
if err := json.Unmarshal(raw, &any); err != nil {
|
|
return nil, err
|
|
}
|
|
walkAndRewriteRefs(any, "")
|
|
return json.Marshal(any)
|
|
}
|
|
|
|
// walkAndRewriteRefs walks arbitrary JSON (map/array) and
|
|
// - when encountering x-kubernetes-group-version-kind, extracts kind,
|
|
// updating the currentKind context;
|
|
// - rewrites all $ref inside the current context from Application* → kind*.
|
|
func walkAndRewriteRefs(node interface{}, currentKind string) {
|
|
switch n := node.(type) {
|
|
case map[string]interface{}:
|
|
if gvk, ok := n["x-kubernetes-group-version-kind"]; ok {
|
|
switch g := gvk.(type) {
|
|
case map[string]interface{}:
|
|
if k, ok := g["kind"].(string); ok {
|
|
currentKind = k
|
|
}
|
|
case []interface{}:
|
|
if len(g) > 0 {
|
|
if mm, ok := g[0].(map[string]interface{}); ok {
|
|
if k, ok := mm["kind"].(string); ok {
|
|
currentKind = k
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for k, v := range n {
|
|
if k == "$ref" && currentKind != "" {
|
|
if s, ok := v.(string); ok {
|
|
n[k] = rewriteRefForKind(s, currentKind)
|
|
continue
|
|
}
|
|
}
|
|
walkAndRewriteRefs(v, currentKind)
|
|
}
|
|
case []interface{}:
|
|
for _, v := range n {
|
|
walkAndRewriteRefs(v, currentKind)
|
|
}
|
|
}
|
|
}
|
|
|
|
// rewriteRefForKind rewrites a reference to a specific kind.
|
|
func rewriteRefForKind(old, kind string) string {
|
|
var base string
|
|
switch {
|
|
case strings.HasPrefix(old, "#/components/schemas/"):
|
|
base = "#/components/schemas/"
|
|
case strings.HasPrefix(old, "#/definitions/"):
|
|
base = "#/definitions/"
|
|
default:
|
|
return old
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(old, ".Application"):
|
|
return base + apiPrefix + "." + kind
|
|
case strings.HasSuffix(old, ".ApplicationList"):
|
|
return base + apiPrefix + "." + kind + "List"
|
|
case strings.HasSuffix(old, ".ApplicationStatus"):
|
|
return base + apiPrefix + "." + kind + "Status"
|
|
default:
|
|
return old
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// OpenAPI **v3** post-processor
|
|
// -----------------------------------------------------------------------------
|
|
func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
|
return func(doc *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
|
|
|
if doc.Components == nil {
|
|
doc.Components = &spec3.Components{}
|
|
}
|
|
if doc.Components.Schemas == nil {
|
|
doc.Components.Schemas = map[string]*spec.Schema{}
|
|
}
|
|
|
|
// Get base schemas
|
|
base, ok1 := doc.Components.Schemas[baseRef]
|
|
list, ok2 := doc.Components.Schemas[baseListRef]
|
|
stat, ok3 := doc.Components.Schemas[baseStatusRef]
|
|
if !(ok1 && ok2 && ok3) && len(kindSchemas) > 0 {
|
|
return doc, fmt.Errorf("base Application* schemas not found")
|
|
}
|
|
|
|
// Clone base schemas for each kind
|
|
for kind, raw := range kindSchemas {
|
|
ref := apiPrefix + "." + kind
|
|
statusRef := ref + "Status"
|
|
listRef := ref + "List"
|
|
|
|
obj, status, l := cloneKindSchemas(kind, base, stat, list /*v3=*/, true)
|
|
doc.Components.Schemas[ref] = obj
|
|
doc.Components.Schemas[statusRef] = status
|
|
doc.Components.Schemas[listRef] = l
|
|
|
|
// patch .spec
|
|
container := findSpecContainer(obj)
|
|
if container == nil {
|
|
container = obj
|
|
}
|
|
if err := patchSpec(container, raw); err != nil {
|
|
return nil, fmt.Errorf("kind %s: %w", kind, err)
|
|
}
|
|
}
|
|
|
|
// Delete base schemas
|
|
delete(doc.Components.Schemas, baseRef)
|
|
delete(doc.Components.Schemas, baseListRef)
|
|
delete(doc.Components.Schemas, baseStatusRef)
|
|
|
|
// Disable strategic-merge-patch+json
|
|
for p, pi := range doc.Paths.Paths {
|
|
if pi != nil && pi.Patch != nil && pi.Patch.RequestBody != nil {
|
|
delete(pi.Patch.RequestBody.Content, smp)
|
|
doc.Paths.Paths[p] = pi
|
|
}
|
|
}
|
|
|
|
// Rewrite all $ref in the document
|
|
out, err := rewriteDocRefs(doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return doc, json.Unmarshal(out, doc)
|
|
}
|
|
}
|
|
|
|
// hasIntAndStringAnyOf returns true if anyOf is exactly a combination of string and integer.
|
|
func hasIntAndStringAnyOf(anyOf []spec.Schema) bool {
|
|
seen := map[string]bool{}
|
|
for i := range anyOf {
|
|
for _, t := range anyOf[i].Type {
|
|
seen[t] = true
|
|
}
|
|
}
|
|
return seen["string"] && seen["integer"] && len(seen) <= 2
|
|
}
|
|
|
|
// sanitizeForV2 removes unsupported constructs for Swagger v2 and normalizes common patterns.
|
|
func sanitizeForV2(s *spec.Schema) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
|
|
if len(s.AnyOf) > 0 {
|
|
if hasIntAndStringAnyOf(s.AnyOf) {
|
|
s.Type = spec.StringOrArray{"string"}
|
|
if s.Extensions == nil {
|
|
s.Extensions = map[string]interface{}{}
|
|
}
|
|
s.Extensions["x-kubernetes-int-or-string"] = true
|
|
}
|
|
s.AnyOf = nil
|
|
}
|
|
|
|
if len(s.OneOf) > 0 {
|
|
s.OneOf = nil
|
|
}
|
|
|
|
if s.AdditionalProperties != nil {
|
|
ap := s.AdditionalProperties
|
|
if ap.Schema != nil {
|
|
sanitizeForV2(ap.Schema)
|
|
}
|
|
}
|
|
|
|
for k := range s.Properties {
|
|
prop := s.Properties[k]
|
|
sanitizeForV2(&prop)
|
|
s.Properties[k] = prop
|
|
}
|
|
|
|
if s.Items != nil {
|
|
if s.Items.Schema != nil {
|
|
sanitizeForV2(s.Items.Schema)
|
|
}
|
|
for i := range s.Items.Schemas {
|
|
sanitizeForV2(&s.Items.Schemas[i])
|
|
}
|
|
}
|
|
|
|
for i := range s.AllOf {
|
|
sanitizeForV2(&s.AllOf[i])
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// OpenAPI **v2** (swagger) post-processor
|
|
// -----------------------------------------------------------------------------
|
|
func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) {
|
|
return func(sw *spec.Swagger) (*spec.Swagger, error) {
|
|
defs := sw.Definitions
|
|
base, ok1 := defs[baseRef]
|
|
list, ok2 := defs[baseListRef]
|
|
stat, ok3 := defs[baseStatusRef]
|
|
if !(ok1 && ok2 && ok3) && len(kindSchemas) > 0 {
|
|
return sw, fmt.Errorf("base Application* schemas not found")
|
|
}
|
|
|
|
for kind, raw := range kindSchemas {
|
|
ref := apiPrefix + "." + kind
|
|
statusRef := ref + "Status"
|
|
listRef := ref + "List"
|
|
|
|
obj, status, l := cloneKindSchemas(kind, &base, &stat, &list, false)
|
|
|
|
if err := patchSpec(obj, raw); err != nil {
|
|
return nil, fmt.Errorf("kind %s: %w", kind, err)
|
|
}
|
|
|
|
defs[ref] = *obj
|
|
defs[statusRef] = *status
|
|
defs[listRef] = *l
|
|
}
|
|
|
|
delete(defs, baseRef)
|
|
delete(defs, baseListRef)
|
|
delete(defs, baseStatusRef)
|
|
|
|
for p, op := range sw.Paths.Paths {
|
|
if op.Patch != nil && len(op.Patch.Consumes) > 0 {
|
|
var out []string
|
|
for _, c := range op.Patch.Consumes {
|
|
if c != smp {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
op.Patch.Consumes = out
|
|
sw.Paths.Paths[p] = op
|
|
}
|
|
}
|
|
|
|
out, err := rewriteDocRefs(sw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(out, sw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for name := range sw.Definitions {
|
|
s := sw.Definitions[name]
|
|
sanitizeForV2(&s)
|
|
sw.Definitions[name] = s
|
|
}
|
|
|
|
return sw, nil
|
|
}
|
|
}
|