Extract schema validation to SDK

Signed-off-by: Mauro Morales <mauro.morales@spectrocloud.com>
This commit is contained in:
Mauro Morales 2023-05-26 12:36:22 +02:00
parent 8e456ec8b6
commit e23346ae44
No known key found for this signature in database
GPG Key ID: 798E0D26D1D964DA
16 changed files with 89 additions and 1049 deletions

8
go.mod
View File

@ -14,7 +14,7 @@ require (
github.com/itchyny/gojq v0.12.12
github.com/jaypipes/ghw v0.10.0
github.com/joho/godotenv v1.5.1
github.com/kairos-io/kairos-sdk v0.0.5
github.com/kairos-io/kairos-sdk v0.0.6-0.20230526103201-c90740d747f8
github.com/labstack/echo/v4 v4.10.2
github.com/mitchellh/mapstructure v1.4.2
github.com/mudler/go-nodepair v0.0.0-20221223092639-ba399a66fdfb
@ -23,14 +23,14 @@ require (
github.com/mudler/yip v1.1.0
github.com/nxadm/tail v1.4.8
github.com/onsi/ginkgo/v2 v2.9.5
github.com/onsi/gomega v1.27.6
github.com/onsi/gomega v1.27.7
github.com/pterm/pterm v0.12.61
github.com/sanity-io/litter v1.5.5
github.com/santhosh-tekuri/jsonschema/v5 v5.3.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.8.1
github.com/swaggest/jsonschema-go v0.3.49
github.com/swaggest/jsonschema-go v0.3.51
github.com/twpayne/go-vfs v1.7.2
github.com/urfave/cli/v2 v2.25.1
github.com/zloylos/grsync v1.7.0
@ -147,7 +147,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.37.0 // indirect
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spectrocloud-labs/herd v0.4.2 // indirect

View File

@ -1,73 +0,0 @@
package agent
import (
"fmt"
"io"
"net/http"
"os"
"strings"
sc "github.com/kairos-io/kairos/v2/pkg/config/schemas"
)
// JSONSchema builds a JSON Schema based on the Root Schema and the given version
// this is helpful when mapping a validation error.
func JSONSchema(version string) (string, error) {
url := fmt.Sprintf("https://kairos.io/%s/cloud-config.json", version)
schema, err := sc.GenerateSchema(sc.RootSchema{}, url)
if err != nil {
return "", err
}
return schema, nil
}
// Validate ensures that a given schema is Valid according to the Root Schema from the agent.
func Validate(source string) error {
var yaml string
if strings.HasPrefix(source, "http") {
resp, err := http.Get(source)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
//Convert the body to type string
yaml = string(body)
} else {
// Maybe we should just try to read the string for the normal headers? That would identify a full yaml vs a file
dat, err := os.ReadFile(source)
if err != nil {
if strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "file name too long") {
yaml = source
} else {
return err
}
} else {
yaml = string(dat)
}
}
config, err := sc.NewConfigFromYAML(yaml, sc.RootSchema{})
if err != nil {
return err
}
if !config.HasHeader() {
return fmt.Errorf("missing #cloud-config header")
}
if config.IsValid() {
return nil
}
err = config.ValidationError
if err != nil {
return err
}
return nil
}

View File

@ -1,107 +0,0 @@
package agent_test
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
. "github.com/kairos-io/kairos/v2/internal/agent"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Validate", func() {
Context("JSONSchema", func() {
It("returns a schema with a url to the given version", func() {
out, err := JSONSchema("0.0.0")
Expect(err).ToNot(HaveOccurred())
Expect(strings.Contains(out, `$schema": "https://kairos.io/0.0.0/cloud-config.json"`)).To(BeTrue())
})
})
Context("Validate", func() {
var yaml string
Context("With a really long config string", func() {
BeforeEach(func() {
yaml = `#cloud-config
users:
- name: kairos
passwd: kairos
vpn:
network_token: "dssdnfjkldashfkjhasdkhfkasjdhfkjhasdjkfhaksjdhfkjashjdkfhioreqwhfuihqweruifhuewrbfhuewrfuyequfhuiehuifheqrihfuiqrehfuirqheiufhreqiuhfuiqheiufhqeuihfuiqrehfiuhqreuifrhiuqehfiuhqeirhfiuewhrfhqwehfriuewhfuihewiuhfruewhrifhwiuehrfiuhweiurfhwueihrfuiwehufhweuihrfuiwerhfuihewruifhewuihfiouwehrfiouhwei"
`
})
It("validates", func() {
Expect(Validate(yaml)).ToNot(HaveOccurred())
})
})
Context("with a valid config", func() {
BeforeEach(func() {
yaml = `#cloud-config
users:
- name: kairos
passwd: kairos`
})
It("is successful reading it from file", func() {
f, err := ioutil.TempDir("", "tests")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(f)
path := filepath.Join(f, "config.yaml")
err = os.WriteFile(path, []byte(yaml), 0655)
Expect(err).ToNot(HaveOccurred())
err = Validate(path)
Expect(err).ToNot(HaveOccurred())
})
It("is successful reading it from a string", func() {
Expect(Validate(yaml)).ToNot(HaveOccurred())
})
})
Context("without a header", func() {
BeforeEach(func() {
yaml = `users:
- name: kairos
passwd: kairos`
})
It("is fails", func() {
f, err := ioutil.TempDir("", "tests")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(f)
path := filepath.Join(f, "config.yaml")
err = os.WriteFile(path, []byte(yaml), 0655)
Expect(err).ToNot(HaveOccurred())
err = Validate(path)
Expect(err).To(MatchError("missing #cloud-config header"))
})
})
Context("with an invalid rule", func() {
BeforeEach(func() {
yaml = `#cloud-config
users:
- name: 007
passwd: kairos`
})
It("is fails", func() {
f, err := ioutil.TempDir("", "tests")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(f)
path := filepath.Join(f, "config.yaml")
err = os.WriteFile(path, []byte(yaml), 0655)
Expect(err).ToNot(HaveOccurred())
err = Validate(path)
Expect(err.Error()).To(MatchRegexp("expected string, but got number"))
})
})
})
})

View File

@ -12,6 +12,7 @@ import (
"text/template"
"time"
"github.com/kairos-io/kairos-sdk/schema"
"github.com/kairos-io/kairos/v2/internal/agent"
"github.com/kairos-io/kairos/v2/pkg/config"
"github.com/labstack/echo/v4"
@ -167,7 +168,7 @@ func Start(ctx context.Context) error {
}
cloudConfig := formData.CloudConfig
err := agent.Validate(cloudConfig)
err := schema.Validate(cloudConfig)
if err != nil {
return c.String(http.StatusOK, err.Error())
}

View File

@ -20,6 +20,7 @@ import (
"github.com/kairos-io/kairos-sdk/bundles"
"github.com/kairos-io/kairos-sdk/machine"
"github.com/kairos-io/kairos-sdk/schema"
"github.com/kairos-io/kairos-sdk/state"
"github.com/kairos-io/kairos/v2/internal/common"
"github.com/kairos-io/kairos/v2/pkg/config"
@ -449,7 +450,7 @@ This command is meant to be used from the boot GRUB menu, but can likely be used
Name: "validate",
Action: func(c *cli.Context) error {
config := c.Args().First()
return agent.Validate(config)
return schema.Validate(config)
},
Usage: "Validates a cloud config file",
Description: `
@ -472,7 +473,7 @@ The validate command expects a configuration file as its only argument. Local fi
version = common.VERSION
}
json, err := agent.JSONSchema(version)
json, err := schema.JSONSchema(version)
if err != nil {
return err

View File

@ -8,8 +8,8 @@ import (
"unicode"
"github.com/kairos-io/kairos-sdk/bundles"
"github.com/kairos-io/kairos-sdk/schema"
"github.com/kairos-io/kairos/v2/pkg/config/collector"
schema "github.com/kairos-io/kairos/v2/pkg/config/schemas"
yip "github.com/mudler/yip/pkg/schema"
"gopkg.in/yaml.v3"

View File

@ -16,11 +16,15 @@
package config_test
import (
"fmt"
"os"
"reflect"
"strings"
// . "github.com/kairos-io/kairos/v2/pkg/config"
. "github.com/kairos-io/kairos-sdk/schema"
. "github.com/kairos-io/kairos/v2/pkg/config"
. "github.com/onsi/ginkgo/v2"
// . "github.com/onsi/gomega"
. "github.com/onsi/gomega"
)
type TConfig struct {
@ -42,3 +46,76 @@ var _ = Describe("Config", func() {
}
})
})
func getTagName(s string) string {
if len(s) < 1 {
return ""
}
if s == "-" {
return ""
}
f := func(c rune) bool {
return c == '"' || c == ','
}
return s[:strings.IndexFunc(s, f)]
}
func structContainsField(f, t string, str interface{}) bool {
values := reflect.ValueOf(str)
types := values.Type()
for j := 0; j < values.NumField(); j++ {
tagName := getTagName(types.Field(j).Tag.Get("json"))
if types.Field(j).Name == f || tagName == t {
return true
} else {
if types.Field(j).Type.Kind() == reflect.Struct {
if types.Field(j).Type.Name() != "" {
model := reflect.New(types.Field(j).Type)
if instance, ok := model.Interface().(OneOfModel); ok {
for _, childSchema := range instance.JSONSchemaOneOf() {
if structContainsField(f, t, childSchema) {
return true
}
}
}
}
}
}
}
return false
}
func structFieldsContainedInOtherStruct(left, right interface{}) {
leftValues := reflect.ValueOf(left)
leftTypes := leftValues.Type()
for i := 0; i < leftValues.NumField(); i++ {
leftTagName := getTagName(leftTypes.Field(i).Tag.Get("yaml"))
leftFieldName := leftTypes.Field(i).Name
if leftTypes.Field(i).IsExported() {
It(fmt.Sprintf("Checks that the new schema contians the field %s", leftFieldName), func() {
Expect(
structContainsField(leftFieldName, leftTagName, right),
).To(BeTrue())
})
}
}
}
var _ = Describe("Schema", func() {
Context("NewConfigFromYAML", func() {
Context("While the new Schema is not the single source of truth", func() {
structFieldsContainedInOtherStruct(Config{}, RootSchema{})
})
Context("While the new InstallSchema is not the single source of truth", func() {
structFieldsContainedInOtherStruct(Install{}, InstallSchema{})
})
Context("While the new BundleSchema is not the single source of truth", func() {
structFieldsContainedInOtherStruct(Bundle{}, BundleSchema{})
})
})
})

View File

@ -1,79 +0,0 @@
package config
import (
jsonschemago "github.com/swaggest/jsonschema-go"
)
// InstallSchema represents the install block in the Kairos configuration. It is used to drive automatic installations without user interaction.
type InstallSchema struct {
_ struct{} `title:"Kairos Schema: Install block" description:"The install block is to drive automatic installations without user interaction."`
Auto bool `json:"auto,omitempty" description:"Set to true when installing without Pairing"`
BindMounts []string `json:"bind_mounts,omitempty"`
Bundles []BundleSchema `json:"bundles,omitempty" description:"Add bundles in runtime"`
Device string `json:"device,omitempty" pattern:"^(auto|/|(/[a-zA-Z0-9_-]+)+)$" description:"Device for automated installs" examples:"[\"auto\",\"/dev/sda\"]"`
EphemeralMounts []string `json:"ephemeral_mounts,omitempty"`
EncryptedPartitions []string `json:"encrypted_partitions,omitempty"`
Env []interface{} `json:"env,omitempty"`
GrubOptionsSchema `json:"grub_options,omitempty"`
Image string `json:"image,omitempty" description:"Use a different container image for the installation"`
PowerManagement
SkipEncryptCopyPlugins bool `json:"skip_copy_kcrypt_plugin,omitempty"`
}
// BundleSchema represents the bundle block which can be used in different places of the Kairos configuration. It is used to reference a bundle and its confguration.
type BundleSchema struct {
DB string `json:"db_path,omitempty"`
LocalFile bool `json:"local_file,omitempty"`
Repository string `json:"repository,omitempty"`
Rootfs string `json:"rootfs_path,omitempty"`
Targets []string `json:"targets,omitempty"`
}
// GrubOptionsSchema represents the grub options block which can be used in different places of the Kairos configuration. It is used to configure grub.
type GrubOptionsSchema struct {
DefaultFallback string `json:"default_fallback,omitempty" description:"Sets default fallback logic"`
DefaultMenuEntry string `json:"default_menu_entry,omitempty" description:"Change GRUB menu entry"`
ExtraActiveCmdline string `json:"extra_active_cmdline,omitempty" description:"Additional Kernel option cmdline to apply just for active"`
ExtraCmdline string `json:"extra_cmdline,omitempty" description:"Additional Kernel option cmdline to apply"`
ExtraPassiveCmdline string `json:"extra_passive_cmdline,omitempty" description:"Additional Kernel option cmdline to apply just for passive"`
ExtraRecoveryCmdline string `json:"extra_recovery_cmdline,omitempty" description:"Set additional boot commands when booting into recovery"`
NextEntry string `json:"next_entry,omitempty" description:"Set the next reboot entry."`
SavedEntry string `json:"saved_entry,omitempty" description:"Set the default boot entry."`
}
// PowerManagement is a meta structure to hold the different rules for managing power, which are not compatible between each other.
type PowerManagement struct {
}
// NoPowerManagement is a meta structure used when the user does not define any power management options or when the user does not want to reboot or poweroff the machine.
type NoPowerManagement struct {
Reboot bool `json:"reboot,omitempty" const:"false" default:"false" description:"Reboot after installation"`
Poweroff bool `json:"poweroff,omitempty" const:"false" default:"false" description:"Power off after installation"`
}
// RebootOnly is a meta structure used to enforce that when the reboot option is set, the poweroff option is not set.
type RebootOnly struct {
Reboot bool `json:"reboot,omitempty" const:"true" default:"false" required:"true" description:"Reboot after installation"`
Poweroff bool `json:"poweroff,omitempty" const:"false" default:"false" description:"Power off after installation"`
}
// PowerOffOnly is a meta structure used to enforce that when the poweroff option is set, the reboot option is not set.
type PowerOffOnly struct {
Reboot bool `json:"reboot,omitempty" const:"false" default:"false" description:"Reboot after installation"`
Poweroff bool `json:"poweroff,omitempty" const:"true" default:"false" required:"true" description:"Power off after installation"`
}
var _ jsonschemago.OneOfExposer = PowerManagement{}
// The OneOfModel interface is only needed for the tests that check the new schemas contain all needed fields
// it can be removed once the new schema is the single source of truth.
type OneOfModel interface {
JSONSchemaOneOf() []interface{}
}
// JSONSchemaOneOf defines that different which are the different valid power management rules and states that one and only one of them needs to be validated for the entire schema to be valid.
func (PowerManagement) JSONSchemaOneOf() []interface{} {
return []interface{}{
NoPowerManagement{}, RebootOnly{}, PowerOffOnly{},
}
}

View File

@ -1,134 +0,0 @@
package config_test
import (
"strings"
. "github.com/kairos-io/kairos/v2/pkg/config/schemas"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Install Schema", func() {
var config *KConfig
var err error
var yaml string
JustBeforeEach(func() {
config, err = NewConfigFromYAML(yaml, InstallSchema{})
Expect(err).ToNot(HaveOccurred())
})
Context("when device is auto", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: auto`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("when device is a path", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: /dev/sda`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("when device is other than a path or auto", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: foobar`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(
strings.Contains(config.ValidationError.Error(),
"does not match pattern '^(auto|/|(/[a-zA-Z0-9_-]+)+)$'",
),
).To(BeTrue())
})
})
Context("when reboot and poweroff are true", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: /dev/sda
reboot: true
poweroff: true`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(config.ValidationError.Error()).To(MatchRegexp("value must be false"))
})
})
Context("when reboot is true and poweroff is false", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: /dev/sda
reboot: true
poweroff: false`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("when reboot is false and poweroff is true", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: /dev/sda
reboot: false
poweroff: true`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("with no power management set", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: /dev/sda`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("with all possible options", func() {
BeforeEach(func() {
yaml = `#cloud-config
device: "/dev/sda"
reboot: true
auto: true
image: "docker:.."
bundles:
- rootfs_path: /usr/local/lib/extensions/<name>
targets:
- container://<image>
grub_options:
extra_cmdline: "config_url=http://"
extra_active_cmdline: "config_url=http://"
extra_passive_cmdline: "config_url=http://"
default_menu_entry: "foobar"
env:
- foo=barevice: /dev/sda`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
})

View File

@ -1,68 +0,0 @@
package config
import (
jsonschemago "github.com/swaggest/jsonschema-go"
)
// P2PSchema represents the P2P block in the Kairos configuration. It is used to enables and configure the p2p full-mesh functionalities.
type P2PSchema struct {
_ struct{} `title:"Kairos Schema: P2P block" description:"The p2p block enables the p2p full-mesh functionalities."`
Role string `json:"role,omitempty" default:"none" enum:"[\"master\",\"worker\",\"none\"]"`
NetworkID string `json:"network_id,omitempty" description:"User defined network-id. Can be used to have multiple clusters in the same network"`
DNS bool `json:"dns,omitempty" description:"Enable embedded DNS See also: https://mudler.github.io/edgevpn/docs/concepts/overview/dns/"`
DisableDHT bool `json:"disable_dht,omitempty" default:"true" description:"Disabling DHT makes co-ordination to discover nodes only in the local network"`
P2PNetworkExtended
VPN `json:"vpn,omitempty"`
}
// KubeVIPSchema represents the kubevip block in the Kairos configuration. It sets the Elastic IP used in KubeVIP. Only valid with p2p.
type KubeVIPSchema struct {
_ struct{} `title:"Kairos Schema: KubeVIP block" description:"Sets the Elastic IP used in KubeVIP. Only valid with p2p"`
EIP string `json:"eip,omitempty" example:"192.168.1.110"`
ManifestURL string `json:"manifest_url,omitempty" description:"Specify a manifest URL for KubeVIP." default:""`
Enable bool `json:"enable,omitempty" description:"Enables KubeVIP"`
Interface bool `json:"interface,omitempty" description:"Specifies a KubeVIP Interface" example:"ens18"`
}
// P2PNetworkExtended is a meta structure to hold the different rules for managing the P2P network, which are not compatible between each other.
type P2PNetworkExtended struct {
}
// P2PAutoDisabled is used to validate that when p2p.auto is disabled, then neither p2p.auto.ha not p2p.network_token can be set.
type P2PAutoDisabled struct {
NetworkToken string `json:"network_token,omitempty" const:"" required:"true"`
Auto struct {
Enable bool `json:"enable" const:"false" required:"true"`
Ha struct {
Enable bool `json:"enable" const:"false"`
} `json:"ha"`
} `json:"auto"`
}
// P2PAutoEnabled is used to validate that when p2p.auto is set, p2p.network_token has to be set.
type P2PAutoEnabled struct {
NetworkToken string `json:"network_token" required:"true" minLength:"1" description:"network_token is the shared secret used by the nodes to co-ordinate with p2p"`
Auto struct {
Enable bool `json:"enable,omitempty" const:"true"`
Ha struct {
Enable bool `json:"enable" const:"true"`
MasterNodes int `json:"master_nodes,omitempty" minimum:"1" description:"Number of HA additional master nodes. A master node is always required for creating the cluster and is implied."`
} `json:"ha"`
} `json:"auto,omitempty"`
}
var _ jsonschemago.OneOfExposer = P2PNetworkExtended{}
// JSONSchemaOneOf defines that different which are the different valid p2p network rules and states that one and only one of them needs to be validated for the entire schema to be valid.
func (P2PNetworkExtended) JSONSchemaOneOf() []interface{} {
return []interface{}{
P2PAutoEnabled{}, P2PAutoDisabled{},
}
}
// VPN represents the vpn block in the Kairos configuration.
type VPN struct {
Create bool `json:"vpn,omitempty" default:"true"`
Use bool `json:"use,omitempty" default:"true"`
Envs []interface{} `json:"env,omitempty"`
}

View File

@ -1,181 +0,0 @@
package config_test
import (
"strings"
. "github.com/kairos-io/kairos/v2/pkg/config/schemas"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("P2P Schema", func() {
var config *KConfig
var err error
var yaml string
JustBeforeEach(func() {
config, err = NewConfigFromYAML(yaml, P2PSchema{})
Expect(err).ToNot(HaveOccurred())
})
Context("with role master", func() {
BeforeEach(func() {
yaml = `#cloud-config
role: master
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="`
})
It("succeeds", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("with role worker", func() {
BeforeEach(func() {
yaml = `#cloud-config
role: worker
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="`
})
It("succeeds", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("with role none", func() {
BeforeEach(func() {
yaml = `#cloud-config
role: none
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="`
})
It("succeeds", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("with other role", func() {
BeforeEach(func() {
yaml = `#cloud-config
role: foobar
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(config.ValidationError.Error()).To(MatchRegexp(`value must be one of "master", "worker", "none"`))
})
})
Context("With a network_token and p2p.auto.enable = false", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="
auto:
enable: false`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(
strings.Contains(config.ValidationError.Error(), `value must be true`),
).To(BeTrue())
})
})
Context("With an empty network_token and p2p.auto.enable = true", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: ""
auto:
enable: true`
})
It("Fails", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(
strings.Contains(config.ValidationError.Error(),
"length must be >= 1, but got 0",
),
).To(BeTrue())
})
})
Context("With a network_token and p2p.auto.enable = true", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="
auto:
enable: true`
})
It("succeeds", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("With a p2p.auto.enable = false and ha.enable = true", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: ""
auto:
enable: false
ha:
enable: true`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(config.ValidationError.Error()).To(MatchRegexp("(length must be >= 1, but got 0|value must be true)"))
})
})
Context("HA with 0 master nodes", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="
auto:
enable: true
ha:
enable: true
master_nodes: 0`
})
It("fails", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(config.ValidationError.Error()).To(MatchRegexp("must be >= 1 but found 0"))
})
})
Context("HA", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="
auto:
enable: true
ha:
enable: true
master_nodes: 2`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("kubevip", func() {
BeforeEach(func() {
yaml = `#cloud-config
network_token: "b3RwOgogIGRoYWdlX3NpemU6IDIwOTcxNTIwCg=="
auto:
enable: true
ha:
enable: true
master_nodes: 2`
})
It("succeedes", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
})

View File

@ -1,106 +0,0 @@
package config
import (
"encoding/json"
"strings"
"github.com/santhosh-tekuri/jsonschema/v5"
jsonschemago "github.com/swaggest/jsonschema-go"
"gopkg.in/yaml.v3"
)
// RootSchema groups all the different schemas of the Kairos configuration together.
type RootSchema struct {
_ struct{} `title:"Kairos Schema" description:"Defines all valid Kairos configuration attributes."`
Bundles []BundleSchema `json:"bundles,omitempty" description:"Add bundles in runtime"`
ConfigURL string `json:"config_url,omitempty" description:"URL download configuration from."`
Env []string `json:"env,omitempty"`
FailOnBundleErrors bool `json:"fail_on_bundles_errors,omitempty"`
GrubOptionsSchema `json:"grub_options,omitempty"`
Install InstallSchema `json:"install,omitempty"`
Options []interface{} `json:"options,omitempty" description:"Various options."`
Users []UserSchema `json:"users,omitempty" minItems:"1" required:"true"`
P2P P2PSchema `json:"p2p,omitempty"`
}
// KConfig is used to parse and validate Kairos configuration files.
type KConfig struct {
Source string
parsed interface{}
ValidationError error
schemaType interface{}
}
// GenerateSchema takes the given schema type and builds a JSON Schema out of it
// if a URL is passed it will also add it as the $schema key, which is useful when
// defining a version of a Root Schema which will be available online.
func GenerateSchema(schemaType interface{}, url string) (string, error) {
reflector := jsonschemago.Reflector{}
generatedSchema, err := reflector.Reflect(schemaType)
if err != nil {
return "", err
}
if url != "" {
generatedSchema.WithSchema(url)
}
generatedSchemaJSON, err := json.MarshalIndent(generatedSchema, "", " ")
if err != nil {
return "", err
}
return string(generatedSchemaJSON), nil
}
func (kc *KConfig) validate() {
generatedSchemaJSON, err := GenerateSchema(kc.schemaType, "")
if err != nil {
kc.ValidationError = err
return
}
sch, err := jsonschema.CompileString("schema.json", string(generatedSchemaJSON))
if err != nil {
kc.ValidationError = err
return
}
if err = sch.Validate(kc.parsed); err != nil {
kc.ValidationError = err
}
}
// IsValid returns true if the schema rules of the configuration are valid.
func (kc *KConfig) IsValid() bool {
kc.validate()
return kc.ValidationError == nil
}
// HasHeader returns true if the config has one of the valid headers.
func (kc *KConfig) HasHeader() bool {
var found bool
availableHeaders := []string{"#cloud-config", "#kairos-config", "#node-config"}
for _, header := range availableHeaders {
if strings.HasPrefix(kc.Source, header) {
found = true
}
}
return found
}
// NewConfigFromYAML is a constructor for KConfig instances. The source of the configuration is passed in YAML and if there are any issues unmarshaling it will return an error.
func NewConfigFromYAML(s string, st interface{}) (*KConfig, error) {
kc := &KConfig{
Source: s,
schemaType: st,
}
err := yaml.Unmarshal([]byte(s), &kc.parsed)
if err != nil {
return kc, err
}
return kc, nil
}

View File

@ -1,190 +0,0 @@
package config_test
import (
"fmt"
"reflect"
"strings"
. "github.com/kairos-io/kairos/v2/pkg/config"
. "github.com/kairos-io/kairos/v2/pkg/config/schemas"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func getTagName(s string) string {
if len(s) < 1 {
return ""
}
if s == "-" {
return ""
}
f := func(c rune) bool {
return c == '"' || c == ','
}
return s[:strings.IndexFunc(s, f)]
}
func structContainsField(f, t string, str interface{}) bool {
values := reflect.ValueOf(str)
types := values.Type()
for j := 0; j < values.NumField(); j++ {
tagName := getTagName(types.Field(j).Tag.Get("json"))
if types.Field(j).Name == f || tagName == t {
return true
} else {
if types.Field(j).Type.Kind() == reflect.Struct {
if types.Field(j).Type.Name() != "" {
model := reflect.New(types.Field(j).Type)
if instance, ok := model.Interface().(OneOfModel); ok {
for _, childSchema := range instance.JSONSchemaOneOf() {
if structContainsField(f, t, childSchema) {
return true
}
}
}
}
}
}
}
return false
}
func structFieldsContainedInOtherStruct(left, right interface{}) {
leftValues := reflect.ValueOf(left)
leftTypes := leftValues.Type()
for i := 0; i < leftValues.NumField(); i++ {
leftTagName := getTagName(leftTypes.Field(i).Tag.Get("yaml"))
leftFieldName := leftTypes.Field(i).Name
if leftTypes.Field(i).IsExported() {
It(fmt.Sprintf("Checks that the new schema contians the field %s", leftFieldName), func() {
Expect(
structContainsField(leftFieldName, leftTagName, right),
).To(BeTrue())
})
}
}
}
var _ = Describe("Schema", func() {
Context("NewConfigFromYAML", func() {
var config *KConfig
var err error
var yaml string
JustBeforeEach(func() {
config, err = NewConfigFromYAML(yaml, RootSchema{})
})
Context("While the new Schema is not the single source of truth", func() {
structFieldsContainedInOtherStruct(Config{}, RootSchema{})
})
Context("While the new InstallSchema is not the single source of truth", func() {
structFieldsContainedInOtherStruct(Install{}, InstallSchema{})
})
Context("While the new BundleSchema is not the single source of truth", func() {
structFieldsContainedInOtherStruct(Bundle{}, BundleSchema{})
})
Context("With invalid YAML syntax", func() {
BeforeEach(func() {
yaml = `#cloud-config
this is:
- invalid
yaml`
})
It("errors", func() {
Expect(err.Error()).To(MatchRegexp("yaml: line 4: could not find expected ':'"))
})
})
Context("When `users` is empty", func() {
BeforeEach(func() {
yaml = `#cloud-config
users: []`
})
It("errors", func() {
Expect(err).ToNot(HaveOccurred())
Expect(config.IsValid()).NotTo(BeTrue())
Expect(config.ValidationError.Error()).To(MatchRegexp("minimum 1 items required, but found 0 items"))
})
})
Context("without a valid header", func() {
BeforeEach(func() {
yaml = `---
users:
- name: kairos
passwd: kairos`
})
It("is successful but HasHeader returns false", func() {
Expect(err).ToNot(HaveOccurred())
Expect(config.HasHeader()).To(BeFalse())
})
})
Context("With a valid config", func() {
BeforeEach(func() {
yaml = `#cloud-config
users:
- name: kairos
passwd: kairos`
})
It("is successful", func() {
Expect(err).ToNot(HaveOccurred())
Expect(config.HasHeader()).To(BeTrue())
})
})
})
Context("GenerateSchema", func() {
var url string
var schema string
var err error
type TestSchema struct {
Key interface{} `json:"key,omitemtpy" required:"true"`
}
JustBeforeEach(func() {
schema, err = GenerateSchema(TestSchema{}, url)
Expect(err).ToNot(HaveOccurred())
})
It("does not include the $schema key by default", func() {
Expect(strings.Contains(schema, `$schema`)).To(BeFalse())
})
It("can use any type of schma", func() {
wants := `{
"required": [
"key"
],
"properties": {
"key": {}
},
"type": "object"
}`
Expect(schema).To(Equal(wants))
})
Context("with a URL", func() {
BeforeEach(func() {
url = "http://foobar"
})
It("appends the $schema key", func() {
Expect(strings.Contains(schema, `$schema": "http://foobar"`)).To(BeTrue())
})
})
})
})

View File

@ -1,13 +0,0 @@
package config_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestConfig(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Config Schemas Suite")
}

View File

@ -1,11 +0,0 @@
package config
// UserSchema represents the users block in the Kairos configuration. It allows the creation of users in the system.
type UserSchema struct {
_ struct{} `title:"Kairos Schema: Users block" description:"The users block allows you to create users in the system."`
Name string `json:"name,omitempty" pattern:"([a-z_][a-z0-9_]{0,30})" required:"true" example:"kairos"`
Passwd string `json:"passwd,omitempty" example:"kairos"`
LockPasswd bool `json:"lockPasswd,omitempty" example:"true"`
Groups []string `json:"groups,omitempty" example:"admin"`
SSHAuthorizedKeys []string `json:"ssh_authorized_keys,omitempty" examples:"[\"github:USERNAME\",\"ssh-ed25519 AAAF00BA5\"]"`
}

View File

@ -1,77 +0,0 @@
package config_test
import (
"strings"
. "github.com/kairos-io/kairos/v2/pkg/config/schemas"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Users Schema", func() {
var config *KConfig
var err error
var yaml string
JustBeforeEach(func() {
config, err = NewConfigFromYAML(yaml, UserSchema{})
Expect(err).ToNot(HaveOccurred())
})
Context("When a user has no name", func() {
BeforeEach(func() {
yaml = `#cloud-config
passwd: foobar`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(config.ValidationError.Error()).To(MatchRegexp("missing properties: 'name'"))
})
})
Context("When a user name doesn't fit the pattern", func() {
BeforeEach(func() {
yaml = `#cloud-config
name: "007"
passwd: "bond"`
})
It("errors", func() {
Expect(config.IsValid()).NotTo(BeTrue())
Expect(
strings.Contains(config.ValidationError.Error(),
"does not match pattern '([a-z_][a-z0-9_]{0,30})'",
),
).To(BeTrue())
})
})
Context("With only the required attributes", func() {
BeforeEach(func() {
yaml = `#cloud-config
name: "kairos"`
})
It("succeeds", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
Context("With all possible attributes", func() {
BeforeEach(func() {
yaml = `#cloud-config
name: "kairos"
passwd: "kairos"
lock_passwd: true
groups:
- "admin"
ssh_authorized_keys:
- github:mudler`
})
It("succeeds", func() {
Expect(config.IsValid()).To(BeTrue())
})
})
})