mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 05:25:14 +09:00
Fix schema-diff correctness: guard nil head on removed child schemas, flag adding a type to a previously-unconstrained field, stop reporting a head becoming unconstrained as narrowing, detect additionalProperties:false, and only recurse items/additionalProperties when both sides carry a schema. Gate resource and group removal as new categories. Filter CRD versions to served ones so the deprecation flow surfaces as a removed version, and flag added x-kubernetes-validations (CEL) rules. Discover first-party cozystack.io CRDs by walking crds/ and definitions/ dirs so a relocated CRD (e.g. cozystack-scheduler) is covered, and error on an empty snapshot. Surface unmapped apiserver storage prefixes and allow hyphens in plurals; drop Markdown from text-mode reports. Workflow: diff against the true merge base, count only each reviewer's latest review on the current head commit, and scope the token to the approval step with persist-credentials:false. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
205 lines
7.1 KiB
Go
205 lines
7.1 KiB
Go
/*
|
|
Copyright 2026 The Cozystack Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package apigate
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
// appsGroup is the fixed API group served by the cozyrd ApplicationDefinitions.
|
|
const appsGroup = "apps.cozystack.io"
|
|
|
|
// ParseCozyRD parses an ApplicationDefinition (cozyrd) manifest into a single
|
|
// apps.cozystack.io Resource. The apps group is served at v1alpha1 only, and
|
|
// its per-resource schema lives in spec.application.openAPISchema as an
|
|
// embedded JSON string.
|
|
func ParseCozyRD(origin string, data []byte) (Resource, bool, error) {
|
|
var doc struct {
|
|
Kind string `json:"kind"`
|
|
Spec struct {
|
|
Application struct {
|
|
Kind string `json:"kind"`
|
|
Plural string `json:"plural"`
|
|
OpenAPISchema string `json:"openAPISchema"`
|
|
} `json:"application"`
|
|
} `json:"spec"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
|
return Resource{}, false, fmt.Errorf("%s: %w", origin, err)
|
|
}
|
|
if doc.Kind != "ApplicationDefinition" {
|
|
return Resource{}, false, nil
|
|
}
|
|
app := doc.Spec.Application
|
|
if app.Plural == "" {
|
|
return Resource{}, false, fmt.Errorf("%s: ApplicationDefinition has empty spec.application.plural", origin)
|
|
}
|
|
res := Resource{
|
|
Group: appsGroup,
|
|
Kind: app.Kind,
|
|
Plural: app.Plural,
|
|
Source: SourceCozyRD,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{},
|
|
}
|
|
if strings.TrimSpace(app.OpenAPISchema) != "" {
|
|
var sch Schema
|
|
if err := json.Unmarshal([]byte(app.OpenAPISchema), &sch); err != nil {
|
|
return Resource{}, false, fmt.Errorf("%s: spec.application.openAPISchema is not valid JSON: %w", origin, err)
|
|
}
|
|
res.Versions["v1alpha1"] = sch
|
|
}
|
|
return res, true, nil
|
|
}
|
|
|
|
// ParseCRDs parses a (possibly multi-document) CustomResourceDefinition YAML
|
|
// file into one Resource per CRD, keyed by spec.group + spec.names.plural with
|
|
// each served version's openAPIV3Schema.
|
|
func ParseCRDs(origin string, data []byte) ([]Resource, error) {
|
|
var out []Resource
|
|
for i, docBytes := range splitYAMLDocs(data) {
|
|
if len(strings.TrimSpace(string(docBytes))) == 0 {
|
|
continue
|
|
}
|
|
var doc struct {
|
|
Kind string `json:"kind"`
|
|
Spec struct {
|
|
Group string `json:"group"`
|
|
Names struct {
|
|
Kind string `json:"kind"`
|
|
Plural string `json:"plural"`
|
|
} `json:"names"`
|
|
Versions []struct {
|
|
Name string `json:"name"`
|
|
Served bool `json:"served"`
|
|
Schema struct {
|
|
OpenAPIV3Schema Schema `json:"openAPIV3Schema"`
|
|
} `json:"schema"`
|
|
} `json:"versions"`
|
|
} `json:"spec"`
|
|
}
|
|
if err := yaml.Unmarshal(docBytes, &doc); err != nil {
|
|
return nil, fmt.Errorf("%s (document %d): %w", origin, i, err)
|
|
}
|
|
if doc.Kind != "CustomResourceDefinition" {
|
|
continue
|
|
}
|
|
if doc.Spec.Group == "" || doc.Spec.Names.Plural == "" {
|
|
return nil, fmt.Errorf("%s (document %d): CRD missing spec.group or spec.names.plural", origin, i)
|
|
}
|
|
res := Resource{
|
|
Group: doc.Spec.Group,
|
|
Kind: doc.Spec.Names.Kind,
|
|
Plural: doc.Spec.Names.Plural,
|
|
Source: SourceCRD,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{},
|
|
}
|
|
for _, v := range doc.Spec.Versions {
|
|
// Only served versions are live API surface. Skipping unserved
|
|
// versions keeps the classifier from diffing schemas no client can
|
|
// call, and makes the standard CRD deprecation step (flip
|
|
// served:true -> false) surface as a removed served version.
|
|
if v.Name == "" || !v.Served {
|
|
continue
|
|
}
|
|
res.Versions[v.Name] = v.Schema.OpenAPIV3Schema
|
|
}
|
|
out = append(out, res)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// storageVarGroups maps the storage-map variable prefixes used in
|
|
// pkg/apiserver/apiserver.go to their API group. The apps map is intentionally
|
|
// omitted: apps.cozystack.io resources are discovered from the cozyrds, which
|
|
// additionally carry their schema. A new entry here (or a new variable prefix
|
|
// in the apiserver) is the signal to extend this table.
|
|
var storageVarGroups = map[string]string{
|
|
"coreV1alpha1Storage": "core.cozystack.io",
|
|
"sdnV1alpha1Storage": "sdn.cozystack.io",
|
|
}
|
|
|
|
// apiserverStorageRe matches `<var>["<plural>"]` storage registrations, e.g.
|
|
//
|
|
// coreV1alpha1Storage["tenantsecrets"] = ...
|
|
//
|
|
// The plural class allows hyphens so a future hyphenated resource name is not
|
|
// silently dropped. The apps storage map is indexed by a variable
|
|
// (appsV1alpha1Storage[resConfig.Application.Plural]), not a string literal, so
|
|
// it never matches here — the apps API is covered by the cozyrds instead.
|
|
var apiserverStorageRe = regexp.MustCompile(`(\w+Storage)\["([a-z0-9-]+)"\]`)
|
|
|
|
// unmappedStoragePrefix labels resources whose storage variable prefix is not
|
|
// in storageVarGroups. Rather than silently dropping them — which would let a
|
|
// contributor add a brand-new aggregated group under a new variable and bypass
|
|
// the gate — they are surfaced under this synthetic group so the classifier
|
|
// reports them as a new group/resource, prompting the reviewer to map the
|
|
// prefix in storageVarGroups.
|
|
const unmappedStoragePrefix = "unmapped-apiserver-storage:"
|
|
|
|
// ParseAPIServerStorages extracts the static Go-backed aggregated resources
|
|
// registered in pkg/apiserver/apiserver.go. Known variable prefixes map to
|
|
// their real group (storageVarGroups); an unknown prefix is surfaced under a
|
|
// synthetic group (unmappedStoragePrefix) so drift cannot silently defeat the
|
|
// gate. These resources carry no checked-in schema, so they populate only the
|
|
// new-group / new-resource / removal detection. Kind is left empty (not
|
|
// recoverable from the registration line alone); reporting falls back to the
|
|
// plural.
|
|
func ParseAPIServerStorages(origin string, data []byte) []Resource {
|
|
var out []Resource
|
|
seen := map[resourceKey]struct{}{}
|
|
for _, m := range apiserverStorageRe.FindAllStringSubmatch(string(data), -1) {
|
|
prefix, plural := m[1], m[2]
|
|
group, ok := storageVarGroups[prefix]
|
|
if !ok {
|
|
group = unmappedStoragePrefix + prefix
|
|
}
|
|
k := resourceKey{Group: group, Plural: plural}
|
|
if _, dup := seen[k]; dup {
|
|
continue
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, Resource{
|
|
Group: group,
|
|
Plural: plural,
|
|
Source: SourceAPIServer,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitYAMLDocs splits a YAML stream into individual document byte slices on
|
|
// `---` separators. It intentionally mirrors the simple splitting Helm and
|
|
// kubectl use; CRD manifests in this repo do not embed `---` inside block
|
|
// scalars, so line-based splitting is safe.
|
|
func splitYAMLDocs(data []byte) [][]byte {
|
|
parts := regexp.MustCompile(`(?m)^---\s*$`).Split(string(data), -1)
|
|
out := make([][]byte, 0, len(parts))
|
|
for _, p := range parts {
|
|
out = append(out, []byte(p))
|
|
}
|
|
return out
|
|
}
|