From 6195b8d38435946763d0d91cb7a995b342311182 Mon Sep 17 00:00:00 2001 From: adohe Date: Sat, 9 Mar 2024 21:58:46 +0800 Subject: [PATCH] refactor: update api package for enhanced maintainability --- pkg/apis/api.kusion.io/group.go | 3 + pkg/apis/api.kusion.io/v1/resource.go | 38 ++ pkg/apis/api.kusion.io/v1/types.go | 291 +++++++++ pkg/apis/internal.kusion.io/group.go | 3 + pkg/apis/internal.kusion.io/v1/marshal.go | 141 +++++ .../internal.kusion.io/v1/marshal_test.go | 557 ++++++++++++++++++ pkg/apis/internal.kusion.io/v1/types.go | 290 +++++++++ pkg/apis/internal.kusion.io/v1/unmarshal.go | 162 +++++ .../internal.kusion.io/v1/unmarshal_test.go | 549 +++++++++++++++++ 9 files changed, 2034 insertions(+) create mode 100644 pkg/apis/api.kusion.io/group.go create mode 100644 pkg/apis/api.kusion.io/v1/resource.go create mode 100644 pkg/apis/api.kusion.io/v1/types.go create mode 100644 pkg/apis/internal.kusion.io/group.go create mode 100644 pkg/apis/internal.kusion.io/v1/marshal.go create mode 100644 pkg/apis/internal.kusion.io/v1/marshal_test.go create mode 100644 pkg/apis/internal.kusion.io/v1/types.go create mode 100644 pkg/apis/internal.kusion.io/v1/unmarshal.go create mode 100644 pkg/apis/internal.kusion.io/v1/unmarshal_test.go diff --git a/pkg/apis/api.kusion.io/group.go b/pkg/apis/api.kusion.io/group.go new file mode 100644 index 00000000..6ad91d61 --- /dev/null +++ b/pkg/apis/api.kusion.io/group.go @@ -0,0 +1,3 @@ +package apikusionio + +const Group = "api.kusion.io" diff --git a/pkg/apis/api.kusion.io/v1/resource.go b/pkg/apis/api.kusion.io/v1/resource.go new file mode 100644 index 00000000..e7ddec35 --- /dev/null +++ b/pkg/apis/api.kusion.io/v1/resource.go @@ -0,0 +1,38 @@ +package v1 + +func (r *Resource) ResourceKey() string { + return r.ID +} + +func (rs Resources) Index() map[string]*Resource { + m := make(map[string]*Resource) + for i := range rs { + m[rs[i].ResourceKey()] = &rs[i] + } + return m +} + +// GVKIndex returns a map of GVK to resources, for now, only Kubernetes resources. +func (rs Resources) GVKIndex() map[string][]*Resource { + m := make(map[string][]*Resource) + for i := range rs { + resource := &rs[i] + if resource.Type != Kubernetes { + continue + } + gvk := resource.Extensions[ResourceExtensionGVK].(string) + m[gvk] = append(m[gvk], resource) + } + return m +} + +func (rs Resources) Len() int { return len(rs) } +func (rs Resources) Swap(i, j int) { rs[i], rs[j] = rs[j], rs[i] } +func (rs Resources) Less(i, j int) bool { + switch { + case rs[i].ID != rs[j].ID: + return rs[i].ID < rs[j].ID + default: + return false + } +} diff --git a/pkg/apis/api.kusion.io/v1/types.go b/pkg/apis/api.kusion.io/v1/types.go new file mode 100644 index 00000000..498f192e --- /dev/null +++ b/pkg/apis/api.kusion.io/v1/types.go @@ -0,0 +1,291 @@ +package v1 + +// Project is a definition of Kusion project resource. +// +// A project is composed of one or more applications and is linked to a Git repository(monorepo or polyrepo), +// which contains the project's desired intent. +type Project struct { + // Name is a required fully qualified name. + Name string `json:"name" yaml:"name"` + // Description is an optional informational description. + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + // Labels is the list of labels that are assigned to this project. + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + // Path is a directory path within the Git repository. + Path string `json:"path,omitempty" yaml:"path,omitempty"` +} + +// Stack is a definition of Kusion stack resource. +// +// Stack provides a mechanism to isolate multiple deployments of same application, it's the target workspace +// where application will be deployed to, the smallest operation unit that can be operated independently. +type Stack struct { + // Name is a required fully qualified name. + Name string `json:"name" yaml:"name"` + // Description is an optional informational description. + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + // Labels is the list of labels that are assigned to this stack. + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + // Path is a directory path within the Git repository. + Path string `json:"path,omitempty" yaml:"path,omitempty"` +} + +// Workspace is a logical concept representing a target that stacks will be deployed to. +// +// Workspace is managed by platform engineers, which contains a set of configurations +// that application developers do not want or should not concern, and is reused by multiple +// stacks belonging to different projects. +type Workspace struct { + // Name identifies a Workspace uniquely. + Name string `yaml:"-" json:"-"` + + // Modules are the configs of a set of modules. + Modules ModuleConfigs `yaml:"modules,omitempty" json:"modules,omitempty"` + + // Runtimes are the configs of a set of runtimes. + Runtimes *RuntimeConfigs `yaml:"runtimes,omitempty" json:"runtimes,omitempty"` + + // SecretStore represents a secure external location for storing secrets. + SecretStore *SecretStoreSpec `yaml:"secretStore,omitempty" json:"secretStore,omitempty"` +} + +// ModuleConfigs is a set of multiple ModuleConfig, whose key is the module name. +// The module name format is "source@version". +type ModuleConfigs map[string]*ModuleConfig + +// GenericConfig is a generic model to describe config which shields the difference among multiple concrete +// models. GenericConfig is designed for extensibility, used for module, terraform runtime config, etc. +type GenericConfig map[string]any + +// ModuleConfig is the config of a module, which contains a default and several patcher blocks. +// +// The default block's key is "default", and value is the module inputs. The patcher blocks' keys +// are the patcher names, which are just block identifiers without specific meaning, but must +// not be "default". Besides module inputs, patcher block's value also contains a field named +// "projectSelector", whose value is a slice containing the project names that use the patcher +// configs. A project can only be assigned in a patcher's "projectSelector" field, the assignment +// in multiple patchers is not allowed. For a project, if not specified in the patcher block's +// "projectSelector" field, the default config will be used. +// +// Take the ModuleConfig of "database" for an example, which is shown as below: +// +// config := ModuleConfig { +// "default": { +// "type": "aws", +// "version": "5.7", +// "instanceType": "db.t3.micro", +// }, +// "smallClass": { +// "instanceType": "db.t3.small", +// "projectSelector": []string{"foo", "bar"}, +// }, +// } +type ModuleConfig struct { + // Default is default block of the module config. + Default GenericConfig `yaml:"default" json:"default"` + + // ModulePatcherConfigs are the patcher blocks of the module config. + ModulePatcherConfigs `yaml:",inline,omitempty" json:",inline,omitempty"` +} + +// ModulePatcherConfigs is a group of ModulePatcherConfig. +type ModulePatcherConfigs map[string]*ModulePatcherConfig + +// ModulePatcherConfig is a patcher block of the module config. +type ModulePatcherConfig struct { + // GenericConfig contains the module configs. + GenericConfig `yaml:",inline" json:",inline"` + + // ProjectSelector contains the selected projects. + ProjectSelector []string `yaml:"projectSelector" json:"projectSelector"` +} + +// RuntimeConfigs contains a set of runtime config. +type RuntimeConfigs struct { + // Kubernetes contains the config to access a kubernetes cluster. + Kubernetes *KubernetesConfig `yaml:"kubernetes,omitempty" json:"kubernetes,omitempty"` + + // Terraform contains the config of multiple terraform providers. + Terraform TerraformConfig `yaml:"terraform,omitempty" json:"terraform,omitempty"` +} + +// KubernetesConfig contains config to access a kubernetes cluster. +type KubernetesConfig struct { + // KubeConfig is the path of the kubeconfig file. + KubeConfig string `yaml:"kubeConfig" json:"kubeConfig"` +} + +// TerraformConfig contains the config of multiple terraform provider config, whose key is +// the provider name. +type TerraformConfig map[string]*ProviderConfig + +// ProviderConfig contains the full configurations of a specified provider. It is the combination +// of the specified provider's config in blocks "terraform/required_providers" and "providers" in +// terraform hcl file, where the former is described by fields Source and Version, and the latter +// is described by GenericConfig cause different provider has different config. +type ProviderConfig struct { + // Source of the provider. + Source string `yaml:"source" json:"source"` + + // Version of the provider. + Version string `yaml:"version" json:"version"` + + // GenericConfig is used to describe the config of a specified terraform provider. + GenericConfig `yaml:",inline,omitempty" json:",inline,omitempty"` +} + +type VaultKVStoreVersion string + +const ( + VaultKVStoreV1 VaultKVStoreVersion = "v1" + VaultKVStoreV2 VaultKVStoreVersion = "v2" +) + +// ExternalSecretRef contains information that points to the secret store data location. +type ExternalSecretRef struct { + // Specifies the name of the secret in Provider to read, mandatory. + Name string `yaml:"name" json:"name"` + + // Specifies the version of the secret to return, if supported. + Version string `yaml:"version,omitempty" json:"version,omitempty"` + + // Used to select a specific property of the secret data (if a map), if supported. + Property string `yaml:"property,omitempty" json:"property,omitempty"` +} + +// SecretStoreSpec contains configuration to describe target secret store. +type SecretStoreSpec struct { + Provider *ProviderSpec `yaml:"provider" json:"provider"` +} + +// ProviderSpec contains provider-specific configuration. +type ProviderSpec struct { + // Alicloud configures a store to retrieve secrets from Alicloud Secrets Manager. + Alicloud *AlicloudProvider `yaml:"alicloud,omitempty" json:"alicloud,omitempty"` + + // AWS configures a store to retrieve secrets from AWS Secrets Manager. + AWS *AWSProvider `yaml:"aws,omitempty" json:"aws,omitempty"` + + // Vault configures a store to retrieve secrets from HashiCorp Vault. + Vault *VaultProvider `yaml:"vault,omitempty" json:"vault,omitempty"` + + // Azure configures a store to retrieve secrets from Azure KeyVault. + Azure *AzureKVProvider `yaml:"azure,omitempty" json:"azure,omitempty"` + + // Fake configures a store with static key/value pairs + Fake *FakeProvider `yaml:"fake,omitempty" json:"fake,omitempty"` +} + +// AlicloudProvider configures a store to retrieve secrets from Alicloud Secrets Manager. +type AlicloudProvider struct { + // Alicloud Region to be used to interact with Alicloud Secrets Manager. + // Examples are cn-beijing, cn-shanghai, etc. + Region string `yaml:"region" json:"region"` +} + +// AWSProvider configures a store to retrieve secrets from AWS Secrets Manager. +type AWSProvider struct { + // AWS Region to be used to interact with AWS Secrets Manager. + // Examples are us-east-1, us-west-2, etc. + Region string `yaml:"region" json:"region"` + + // The profile to be used to interact with AWS Secrets Manager. + // If not set, the default profile created with `aws configure` will be used. + Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` +} + +// VaultProvider configures a store to retrieve secrets from HashiCorp Vault. +type VaultProvider struct { + // Server is the target Vault server address to connect, e.g: "https://vault.example.com:8200". + Server string `yaml:"server" json:"server"` + + // Path is the mount path of the Vault KV backend endpoint, e.g: "secret". + Path *string `yaml:"path,omitempty" json:"path,omitempty"` + + // Version is the Vault KV secret engine version. Version can be either "v1" or + // "v2", defaults to "v2". + Version VaultKVStoreVersion `yaml:"version" json:"version"` +} + +// AzureEnvironmentType specifies the Azure cloud environment endpoints to use for connecting and authenticating with Azure. +type AzureEnvironmentType string + +const ( + AzureEnvironmentPublicCloud AzureEnvironmentType = "PublicCloud" + AzureEnvironmentUSGovernmentCloud AzureEnvironmentType = "USGovernmentCloud" + AzureEnvironmentChinaCloud AzureEnvironmentType = "ChinaCloud" + AzureEnvironmentGermanCloud AzureEnvironmentType = "GermanCloud" +) + +// AzureKVProvider configures a store to retrieve secrets from Azure KeyVault +type AzureKVProvider struct { + // Vault Url from which the secrets to be fetched from. + VaultURL *string `yaml:"vaultUrl" json:"vaultUrl"` + + // TenantID configures the Azure Tenant to send requests to. + TenantID *string `yaml:"tenantId" json:"tenantId"` + + // EnvironmentType specifies the Azure cloud environment endpoints to use for connecting and authenticating with Azure. + // By-default it points to the public cloud AAD endpoint, and the following endpoints are available: + // PublicCloud, USGovernmentCloud, ChinaCloud, GermanCloud + // Ref: https://github.com/Azure/go-autorest/blob/main/autorest/azure/environments.go#L152 + EnvironmentType AzureEnvironmentType `yaml:"environmentType,omitempty" json:"environmentType,omitempty"` +} + +// FakeProvider configures a fake provider that returns static values. +type FakeProvider struct { + Data []FakeProviderData `json:"data"` +} + +type FakeProviderData struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` + ValueMap map[string]string `json:"valueMap,omitempty"` + Version string `json:"version,omitempty"` +} + +type Type string + +const ( + Kubernetes Type = "Kubernetes" + Terraform Type = "Terraform" +) + +const ( + // ResourceExtensionGVK is the key for resource extension, which is used to + // store the GVK of the resource. + ResourceExtensionGVK = "GVK" + // ResourceExtensionKubeConfig is the key for resource extension, which is used + // to indicate the path of kubeConfig for Kubernetes type resource. + ResourceExtensionKubeConfig = "kubeConfig" +) + +// Spec describes the desired state how the infrastructure should look like: which workload to run, +// the load-balancer setup, the location of the database schema, and so on. Based on that information, +// the Kusion engine takes care of updating the production state to match the Intent. +type Spec struct { + // Resources is the list of Resource this Spec contains. + Resources Resources `json:"resources" yaml:"resources"` +} + +type Resources []Resource + +// Resource is the representation of a resource in the state. +type Resource struct { + // ID is the unique key of this resource in the whole State. + // ApiVersion:Kind:Namespace:Name is an idiomatic way for Kubernetes resources. + // providerNamespace:providerName:resourceType:resourceName for Terraform resources + ID string `json:"id" yaml:"id"` + + // Type represents all Runtimes we supported like Kubernetes and Terraform + Type Type `json:"type" yaml:"type"` + + // Attributes represents all specified attributes of this resource + Attributes map[string]interface{} `json:"attributes" yaml:"attributes"` + + // DependsOn contains all resources this resource depends on + DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` + + // Extensions specifies arbitrary metadata of this resource + Extensions map[string]interface{} `json:"extensions,omitempty" yaml:"extensions,omitempty"` +} diff --git a/pkg/apis/internal.kusion.io/group.go b/pkg/apis/internal.kusion.io/group.go new file mode 100644 index 00000000..0864596c --- /dev/null +++ b/pkg/apis/internal.kusion.io/group.go @@ -0,0 +1,3 @@ +package internalkusionio + +const Group = "internal.kusion.io" diff --git a/pkg/apis/internal.kusion.io/v1/marshal.go b/pkg/apis/internal.kusion.io/v1/marshal.go new file mode 100644 index 00000000..23f4a08b --- /dev/null +++ b/pkg/apis/internal.kusion.io/v1/marshal.go @@ -0,0 +1,141 @@ +package v1 + +import ( + "encoding/json" + "errors" +) + +// MarshalJSON implements the json.Marshaler interface for ProbeHandler. +func (p *ProbeHandler) MarshalJSON() ([]byte, error) { + switch p.Type { + case "Http": + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *HTTPGetAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{p.Type}, + HTTPGetAction: p.HTTPGetAction, + }) + case "Exec": + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *ExecAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{p.Type}, + ExecAction: p.ExecAction, + }) + case "Tcp": + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *TCPSocketAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{p.Type}, + TCPSocketAction: p.TCPSocketAction, + }) + default: + return nil, errors.New("unrecognized probe handler type") + } +} + +// MarshalYAML implements the yaml.Marshaler interface for ProbeHandler. +func (p *ProbeHandler) MarshalYAML() (interface{}, error) { + switch p.Type { + case "Http": + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + HTTPGetAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: p.Type}, + HTTPGetAction: *p.HTTPGetAction, + }, nil + case "Exec": + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + ExecAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: p.Type}, + ExecAction: *p.ExecAction, + }, nil + case "Tcp": + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + TCPSocketAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: p.Type}, + TCPSocketAction: *p.TCPSocketAction, + }, nil + } + + return nil, nil +} + +// MarshalJSON implements the json.Marshaler interface for LifecycleHandler. +func (l *LifecycleHandler) MarshalJSON() ([]byte, error) { + switch l.Type { + case "Http": + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *HTTPGetAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{l.Type}, + HTTPGetAction: l.HTTPGetAction, + }) + case "Exec": + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *ExecAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{l.Type}, + ExecAction: l.ExecAction, + }) + default: + return nil, errors.New("unrecognized lifecycle handler type") + } +} + +// MarshalYAML implements the yaml.Marshaler interface for LifecycleHandler. +func (l *LifecycleHandler) MarshalYAML() (interface{}, error) { + switch l.Type { + case "Http": + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + HTTPGetAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: l.Type}, + HTTPGetAction: *l.HTTPGetAction, + }, nil + case "Exec": + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + ExecAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: l.Type}, + ExecAction: *l.ExecAction, + }, nil + default: + return nil, errors.New("unrecognized lifecycle handler type") + } +} + +func (w *Workload) MarshalJSON() ([]byte, error) { + switch w.Header.Type { + case TypeService: + return json.Marshal(struct { + Header `yaml:",inline" json:",inline"` + *Service `json:",inline"` + }{ + Header: Header{w.Header.Type}, + Service: w.Service, + }) + case TypeJob: + return json.Marshal(struct { + Header `yaml:",inline" json:",inline"` + *Job `json:",inline"` + }{ + Header: Header{w.Header.Type}, + Job: w.Job, + }) + default: + return nil, errors.New("unknown workload type") + } +} diff --git a/pkg/apis/internal.kusion.io/v1/marshal_test.go b/pkg/apis/internal.kusion.io/v1/marshal_test.go new file mode 100644 index 00000000..47894c27 --- /dev/null +++ b/pkg/apis/internal.kusion.io/v1/marshal_test.go @@ -0,0 +1,557 @@ +package v1 + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" +) + +func TestContainerMarshalJSON(t *testing.T) { + cases := []struct { + input Container + result string + }{ + { + input: Container{ + Image: "nginx:v1", + Resources: map[string]string{ + "cpu": "4", + "memory": "8Gi", + }, + Files: map[string]FileSpec{ + "/tmp/test.txt": { + Content: "hello world", + Mode: "0644", + }, + }, + }, + result: `{"image":"nginx:v1","resources":{"cpu":"4","memory":"8Gi"},"files":{"/tmp/test.txt":{"content":"hello world","mode":"0644"}}}`, + }, + { + input: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, + }, + { + input: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"cat", "/tmp/healthy"}, + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, + }, + { + input: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Tcp"}, + TCPSocketAction: &TCPSocketAction{ + URL: "127.0.0.1:8080", + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, + }, + { + input: Container{ + Image: "nginx:v1", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, + }, + }, + }, + }, + result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"Exec","command":["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]}}}`, + }, + { + input: Container{ + Image: "nginx:v1", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + }, + }, + result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Http","url":"http://localhost:80"},"postStart":{"_type":"Http","url":"http://localhost:80"}}}`, + }, + } + + for _, c := range cases { + result, err := json.Marshal(&c.input) + if err != nil { + t.Errorf("Failed to marshal input: '%v': %v", c.input, err) + } + if string(result) != c.result { + t.Errorf("Failed to marshal input: '%v': expected %+v, got %q", c.input, c.result, string(result)) + } + } +} + +func TestContainerMarshalYAML(t *testing.T) { + cases := []struct { + input Container + result string + }{ + { + input: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + }, + result: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +`, + }, + { + input: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +readinessProbe: + probeHandler: + _type: Http + url: http://localhost:80 + initialDelaySeconds: 10 +`, + }, + { + input: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Exec"}, + ExecAction: &ExecAction{ + Command: []string{"cat", "/tmp/healthy"}, + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +readinessProbe: + probeHandler: + _type: Exec + command: + - cat + - /tmp/healthy + initialDelaySeconds: 10 +`, + }, + { + input: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Tcp"}, + TCPSocketAction: &TCPSocketAction{ + URL: "127.0.0.1:8080", + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +readinessProbe: + probeHandler: + _type: Tcp + url: 127.0.0.1:8080 + initialDelaySeconds: 10 +`, + }, + { + input: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, + }, + }, + }, + }, + result: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +lifecycle: + preStop: + _type: Exec + command: + - /bin/sh + - -c + - echo Hello from the postStart handler > /usr/share/message + postStart: + _type: Exec + command: + - /bin/sh + - -c + - nginx -s quit; while killall -0 nginx; do sleep 1; done +`, + }, + { + input: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + }, + }, + result: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +lifecycle: + preStop: + _type: Http + url: http://localhost:80 + postStart: + _type: Http + url: http://localhost:80 +`, + }, + } + + for _, c := range cases { + result, err := yaml.Marshal(&c.input) + if err != nil { + t.Errorf("Failed to marshal input: '%v': %v", c.input, err) + } + if string(result) != c.result { + t.Errorf("Failed to marshal input: '%v': expected %+v, got %q", c.input, c.result, string(result)) + } + } +} + +func TestWorkloadMarshalJSON(t *testing.T) { + r2 := int32(2) + tests := []struct { + name string + data *Workload + expected string + expectedError error + }{ + { + name: "Valid MarshalJSON for Service", + data: &Workload{ + Header: Header{ + Type: TypeService, + }, + Service: &Service{ + Type: "Deployment", + Base: Base{ + Replicas: &r2, + Labels: map[string]string{ + "app": "my-service", + }, + }, + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expected: `{"_type": "Service", "replicas": 2, "labels": {"app": "my-service"}, "type": "Deployment"}`, + expectedError: nil, + }, + { + name: "Valid MarshalJSON for Job", + data: &Workload{ + Header: Header{ + Type: TypeJob, + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expected: `{"_type": "Job", "schedule": "* * * * *"}`, + expectedError: nil, + }, + { + name: "Unknown _Type", + data: &Workload{ + Header: Header{ + Type: "Unknown", + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expected: "", + expectedError: errors.New("unknown workload type"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, actualErr := json.Marshal(test.data) + if test.expectedError == nil { + assert.JSONEq(t, test.expected, string(actual)) + assert.NoError(t, actualErr) + } else { + assert.ErrorContains(t, actualErr, test.expectedError.Error()) + } + }) + } +} + +func TestWorkloadMarshalYAML(t *testing.T) { + r2 := int32(2) + tests := []struct { + name string + workload *Workload + expected string + expectedError error + }{ + { + name: "Valid MarshalYAML for Service", + workload: &Workload{ + Header: Header{ + Type: TypeService, + }, + Service: &Service{ + Type: "Deployment", + Base: Base{ + Replicas: &r2, + Labels: map[string]string{ + "app": "my-service", + }, + }, + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expected: `_type: Service +replicas: 2 +labels: + app: my-service +type: Deployment`, + expectedError: nil, + }, + { + name: "Valid MarshalYAML for Job", + workload: &Workload{ + Header: Header{ + Type: TypeJob, + }, + Service: &Service{ + Type: "Deployment", + Base: Base{ + Replicas: &r2, + Labels: map[string]string{ + "app": "my-service", + }, + }, + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expected: `_type: Job +schedule: '* * * * *'`, + expectedError: nil, + }, + { + name: "Unknown _Type", + workload: &Workload{ + Header: Header{ + Type: "Unknown", + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expected: "", + expectedError: errors.New("unknown workload type"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, actualErr := yaml.Marshal(test.workload) + if test.expectedError == nil { + assert.YAMLEq(t, test.expected, string(actual)) + assert.NoError(t, actualErr) + } else { + assert.ErrorContains(t, actualErr, test.expectedError.Error()) + } + }) + } +} diff --git a/pkg/apis/internal.kusion.io/v1/types.go b/pkg/apis/internal.kusion.io/v1/types.go new file mode 100644 index 00000000..28c6f3af --- /dev/null +++ b/pkg/apis/internal.kusion.io/v1/types.go @@ -0,0 +1,290 @@ +package v1 + +import ( + "gopkg.in/yaml.v2" +) + +// Container describes how the App's tasks are expected to be run. +type Container struct { + // Image to run for this container + Image string `yaml:"image" json:"image"` + // Entrypoint array. + // The image's ENTRYPOINT is used if this is not provided. + Command []string `yaml:"command,omitempty" json:"command,omitempty"` + // Arguments to the entrypoint. + // The image's CMD is used if this is not provided. + Args []string `yaml:"args,omitempty" json:"args,omitempty"` + // Collection of environment variables to set in the container. + // The value of environment variable may be static text or a value from a secret. + Env yaml.MapSlice `yaml:"env,omitempty" json:"env,omitempty"` + // The current working directory of the running process defined in entrypoint. + WorkingDir string `yaml:"workingDir,omitempty" json:"workingDir,omitempty"` + // Resource requirements for this container. + Resources map[string]string `yaml:"resources,omitempty" json:"resources,omitempty"` + // Files configures one or more files to be created in the container. + Files map[string]FileSpec `yaml:"files,omitempty" json:"files,omitempty"` + // Dirs configures one or more volumes to be mounted to the specified folder. + Dirs map[string]string `yaml:"dirs,omitempty" json:"dirs,omitempty"` + // Periodic probe of container liveness. + LivenessProbe *Probe `yaml:"livenessProbe,omitempty" json:"livenessProbe,omitempty"` + // Periodic probe of container service readiness. + ReadinessProbe *Probe `yaml:"readinessProbe,omitempty" json:"readinessProbe,omitempty"` + // StartupProbe indicates that the Pod has successfully initialized. + StartupProbe *Probe `yaml:"startupProbe,omitempty" json:"startupProbe,omitempty"` + // Actions that the management system should take in response to container lifecycle events. + Lifecycle *Lifecycle `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"` +} + +// FileSpec defines the target file in a Container +type FileSpec struct { + // The content of target file in plain text. + Content string `yaml:"content,omitempty" json:"content,omitempty"` + // Source for the file content, might be a reference to a secret value. + ContentFrom string `yaml:"contentFrom,omitempty" json:"contentFrom,omitempty"` + // Mode bits used to set permissions on this file. + Mode string `yaml:"mode" json:"mode"` +} + +// TypeWrapper is a thin wrapper to make YAML decoder happy. +type TypeWrapper struct { + // Type of action to be taken. + Type string `yaml:"_type" json:"_type"` +} + +// Probe describes a health check to be performed against a container to determine whether it is +// alive or ready to receive traffic. +type Probe struct { + // The action taken to determine the health of a container. + ProbeHandler *ProbeHandler `yaml:"probeHandler" json:"probeHandler"` + // Number of seconds after the container has started before liveness probes are initiated. + InitialDelaySeconds int32 `yaml:"initialDelaySeconds,omitempty" json:"initialDelaySeconds,omitempty"` + // Number of seconds after which the probe times out. + TimeoutSeconds int32 `yaml:"timeoutSeconds,omitempty" json:"timeoutSeconds,omitempty"` + // How often (in seconds) to perform the probe. + PeriodSeconds int32 `yaml:"periodSeconds,omitempty" json:"periodSeconds,omitempty"` + // Minimum consecutive successes for the probe to be considered successful after having failed. + SuccessThreshold int32 `yaml:"successThreshold,omitempty" json:"successThreshold,omitempty"` + // Minimum consecutive failures for the probe to be considered failed after having succeeded. + FailureThreshold int32 `yaml:"failureThreshold,omitempty" json:"failureThreshold,omitempty"` +} + +// ProbeHandler defines a specific action that should be taken in a probe. +// One and only one of the fields must be specified. +type ProbeHandler struct { + // Type of action to be taken. + TypeWrapper `yaml:"_type" json:"_type"` + // Exec specifies the action to take. + // +optional + *ExecAction `yaml:",inline" json:",inline"` + // HTTPGet specifies the http request to perform. + // +optional + *HTTPGetAction `yaml:",inline" json:",inline"` + // TCPSocket specifies an action involving a TCP port. + // +optional + *TCPSocketAction `yaml:",inline" json:",inline"` +} + +// ExecAction describes a "run in container" action. +type ExecAction struct { + // Command is the command line to execute inside the container, the working directory for the + // command is root ('/') in the container's filesystem. + // Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + Command []string `yaml:"command,omitempty" json:"command,omitempty"` +} + +// HTTPGetAction describes an action based on HTTP Get requests. +type HTTPGetAction struct { + // URL is the full qualified url location to send HTTP requests. + URL string `yaml:"url,omitempty" json:"url,omitempty"` + // Custom headers to set in the request. HTTP allows repeated headers. + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` +} + +// TCPSocketAction describes an action based on opening a socket. +type TCPSocketAction struct { + // URL is the full qualified url location to open a socket. + URL string `yaml:"url,omitempty" json:"url,omitempty"` +} + +// Lifecycle describes actions that the management system should take in response +// to container lifecycle events. +type Lifecycle struct { + // PreStop is called immediately before a container is terminated due to an + // API request or management event such as liveness/startup probe failure, + // preemption, resource contention, etc. + PreStop *LifecycleHandler `yaml:"preStop,omitempty" json:"preStop,omitempty"` + // PostStart is called immediately after a container is created. + PostStart *LifecycleHandler `yaml:"postStart,omitempty" json:"postStart,omitempty"` +} + +// LifecycleHandler defines a specific action that should be taken in a lifecycle +// hook. One and only one of the fields, except TCPSocket must be specified. +type LifecycleHandler struct { + // Type of action to be taken. + TypeWrapper `yaml:"_type" json:"_type"` + // Exec specifies the action to take. + // +optional + *ExecAction `yaml:",inline" json:",inline"` + // HTTPGet specifies the http request to perform. + // +optional + *HTTPGetAction `yaml:",inline" json:",inline"` +} + +type Protocol string + +const ( + TCP Protocol = "TCP" + UDP Protocol = "UDP" +) + +// Port defines the exposed port of Service. +type Port struct { + // Port is the exposed port of the Service. + Port int `yaml:"port,omitempty" json:"port,omitempty"` + // TargetPort is the backend .Container port. + TargetPort int `yaml:"targetPort,omitempty" json:"targetPort,omitempty"` + // Protocol is protocol used to expose the port, support ProtocolTCP and ProtocolUDP. + Protocol Protocol `yaml:"protocol,omitempty" json:"protocol,omitempty"` +} + +type Secret struct { + Type string `yaml:"type" json:"type"` + Params map[string]string `yaml:"params,omitempty" json:"params,omitempty"` + Data map[string]string `yaml:"data,omitempty" json:"data,omitempty"` + Immutable bool `yaml:"immutable,omitempty" json:"immutable,omitempty"` +} + +// Base defines set of attributes shared by different workload profile, e.g. Service and Job. +type Base struct { + // The templates of containers to be run. + Containers map[string]Container `yaml:"containers,omitempty" json:"containers,omitempty"` + // The number of containers that should be run. + Replicas *int32 `yaml:"replicas,omitempty" json:"replicas,omitempty"` + // Secret + Secrets map[string]Secret `json:"secrets,omitempty" yaml:"secrets,omitempty"` + // Dirs configures one or more volumes to be mounted to the specified folder. + Dirs map[string]string `json:"dirs,omitempty" yaml:"dirs,omitempty"` + // Labels and Annotations can be used to attach arbitrary metadata as key-value pairs to resources. + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +type ServiceType string + +const ( + Deployment ServiceType = "Deployment" + Collaset ServiceType = "CollaSet" +) + +// Service is a kind of workload profile that describes how to run your application code. +// This is typically used for long-running web applications that should "never" go down, and handle short-lived latency-sensitive +// web requests, or events. +type Service struct { + Base `yaml:",inline" json:",inline"` + // Type represents the type of workload.Service, support Deployment and CollaSet. + Type ServiceType `yaml:"type" json:"type"` + // Ports describe the list of ports need getting exposed. + Ports []Port `yaml:"ports,omitempty" json:"ports,omitempty"` +} + +// Job is a kind of workload profile that describes how to run your application code. This is typically used for tasks that take from +// a few seconds to a few days to complete. +type Job struct { + Base `yaml:",inline" json:",inline"` + // The scheduling strategy in Cron format: https://en.wikipedia.org/wiki/Cron. + Schedule string `yaml:"schedule,omitempty" json:"schedule,omitempty"` +} + +type Type string + +const ( + TypeJob = "Job" + TypeService = "Service" + FieldReplicas = "replicas" +) + +type Header struct { + Type string `yaml:"_type" json:"_type"` +} + +type Workload struct { + Header `yaml:",inline" json:",inline"` + *Service `yaml:",inline" json:",inline"` + *Job `yaml:",inline" json:",inline"` +} + +type Accessory map[string]interface{} + +// AppConfiguration is a developer-centric definition that describes how to run an App. The application model is built on a decade +// of experience from AntGroup in operating a large-scale internal developer platform and combines the best ideas and practices from the +// community. +// +// Note: AppConfiguration per se is not a Kusion Module +// +// Example: +// import models.schema.v1 as ac +// import models.schema.v1.workload as wl +// import models.schema.v1.workload.container as c +// import models.schema.v1.workload.container.probe as p +// import models.schema.v1.monitoring as m +// import models.schema.v1.database as d +// +// helloWorld: ac.AppConfiguration { +// # Built-in module +// workload: wl.Service { +// containers: { +// "main": c.Container { +// image: "ghcr.io/kusion-stack/samples/helloworld:latest" +// # Configure a HTTP readiness probe +// readinessProbe: p.Probe { +// probeHandler: p.Http { +// url: "http://localhost:80" +// } +// } +// } +// } +// } +// +// # extend accessories module base +// accessories: { +// # Built-in module, key represents the module source +// "kusionstack/mysql@v0.1" : d.MySQL { +// type: "cloud" +// version: "8.0" +// } +// # Built-in module, key represents the module source +// "kusionstack/prometheus@v0.1" : m.Prometheus { +// path: "/metrics" +// } +// # Customized module, key represents the module source +// "foo/customize": customizedModule { +// ... +// } +// } +// +// # extend pipeline module base +// pipeline: { +// # Step is a module +// "step" : Step { +// use: "exec" +// args: ["--test-all"] +// } +// } +// +// # Dependent app list +// dependency: { +// dependentApps: ["init-kusion"] +// } +// } +type AppConfiguration struct { + // Name of the target App. + Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Workload defines how to run your application code. + Workload *Workload `json:"workload" yaml:"workload"` + // Accessories defines a collection of accessories that will be attached to the workload. + // The key in this map represents the module source. e.g. kusionstack/mysql@v0.1 + Accessories map[string]*Accessory `json:"accessories,omitempty" yaml:"accessories,omitempty"` + // Labels and Annotations can be used to attach arbitrary metadata as key-value pairs to resources. + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} diff --git a/pkg/apis/internal.kusion.io/v1/unmarshal.go b/pkg/apis/internal.kusion.io/v1/unmarshal.go new file mode 100644 index 00000000..a314e40d --- /dev/null +++ b/pkg/apis/internal.kusion.io/v1/unmarshal.go @@ -0,0 +1,162 @@ +package v1 + +import ( + "encoding/json" + "errors" +) + +// UnmarshalJSON implements the json.Unmarshaller interface for ProbeHandler. +func (p *ProbeHandler) UnmarshalJSON(data []byte) error { + var probeType TypeWrapper + err := json.Unmarshal(data, &probeType) + if err != nil { + return err + } + + p.Type = probeType.Type + switch p.Type { + case "Http": + handler := &HTTPGetAction{} + err = json.Unmarshal(data, handler) + p.HTTPGetAction = handler + case "Exec": + handler := &ExecAction{} + err = json.Unmarshal(data, handler) + p.ExecAction = handler + case "Tcp": + handler := &TCPSocketAction{} + err = json.Unmarshal(data, handler) + p.TCPSocketAction = handler + default: + return errors.New("unrecognized probe handler type") + } + + return err +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for ProbeHandler. +func (p *ProbeHandler) UnmarshalYAML(unmarshal func(interface{}) error) error { + var probeType TypeWrapper + err := unmarshal(&probeType) + if err != nil { + return err + } + + p.Type = probeType.Type + switch p.Type { + case "Http": + handler := &HTTPGetAction{} + err = unmarshal(handler) + p.HTTPGetAction = handler + case "Exec": + handler := &ExecAction{} + err = unmarshal(handler) + p.ExecAction = handler + case "Tcp": + handler := &TCPSocketAction{} + err = unmarshal(handler) + p.TCPSocketAction = handler + default: + return errors.New("unrecognized probe handler type") + } + + return err +} + +// UnmarshalJSON implements the json.Unmarshaller interface for LifecycleHandler. +func (l *LifecycleHandler) UnmarshalJSON(data []byte) error { + var handlerType TypeWrapper + err := json.Unmarshal(data, &handlerType) + if err != nil { + return err + } + + l.Type = handlerType.Type + switch l.Type { + case "Http": + handler := &HTTPGetAction{} + err = json.Unmarshal(data, handler) + l.HTTPGetAction = handler + case "Exec": + handler := &ExecAction{} + err = json.Unmarshal(data, handler) + l.ExecAction = handler + default: + return errors.New("unrecognized lifecycle handler type") + } + + return err +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for LifecycleHandler. +func (l *LifecycleHandler) UnmarshalYAML(unmarshal func(interface{}) error) error { + var handlerType TypeWrapper + err := unmarshal(&handlerType) + if err != nil { + return err + } + + l.Type = handlerType.Type + switch l.Type { + case "Http": + handler := &HTTPGetAction{} + err = unmarshal(handler) + l.HTTPGetAction = handler + case "Exec": + handler := &ExecAction{} + err = unmarshal(handler) + l.ExecAction = handler + default: + return errors.New("unrecognized lifecycle handler type") + } + + return err +} + +func (w *Workload) UnmarshalJSON(data []byte) error { + var workloadData Header + err := json.Unmarshal(data, &workloadData) + if err != nil { + return err + } + + w.Header.Type = workloadData.Type + switch w.Header.Type { + case TypeJob: + var v Job + err = json.Unmarshal(data, &v) + w.Job = &v + case TypeService: + var v Service + err = json.Unmarshal(data, &v) + w.Service = &v + default: + err = errors.New("unknown workload type") + } + + return err +} + +func (w *Workload) UnmarshalYAML(unmarshal func(interface{}) error) error { + var workloadData Header + err := unmarshal(&workloadData) + if err != nil { + return err + } + + w.Header.Type = workloadData.Type + switch w.Header.Type { + case TypeJob: + var v Job + err = unmarshal(&v) + w.Job = &v + case TypeService: + var v Service + err = unmarshal(&v) + w.Service = &v + default: + err = errors.New("unknown workload type") + } + + return err +} diff --git a/pkg/apis/internal.kusion.io/v1/unmarshal_test.go b/pkg/apis/internal.kusion.io/v1/unmarshal_test.go new file mode 100644 index 00000000..47f9ab30 --- /dev/null +++ b/pkg/apis/internal.kusion.io/v1/unmarshal_test.go @@ -0,0 +1,549 @@ +package v1 + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" +) + +func TestContainerUnmarshalJSON(t *testing.T) { + cases := []struct { + input string + result Container + }{ + { + input: `{"image":"nginx:v1","resources":{"cpu":"4","memory":"8Gi"},"files":{"/tmp/test.txt":{"content":"hello world","mode":"0644"}}}`, + result: Container{ + Image: "nginx:v1", + Resources: map[string]string{ + "cpu": "4", + "memory": "8Gi", + }, + Files: map[string]FileSpec{ + "/tmp/test.txt": { + Content: "hello world", + Mode: "0644", + }, + }, + }, + }, + { + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, + result: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, + result: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Exec"}, + ExecAction: &ExecAction{ + Command: []string{"cat", "/tmp/healthy"}, + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, + result: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Tcp"}, + TCPSocketAction: &TCPSocketAction{ + URL: "127.0.0.1:8080", + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"Exec","command":["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]}}}`, + result: Container{ + Image: "nginx:v1", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, + }, + }, + }, + }, + }, + { + input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"Http","url":"http://localhost:80"},"postStart":{"_type":"Http","url":"http://localhost:80"}}}`, + result: Container{ + Image: "nginx:v1", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + }, + }, + }, + } + + for _, c := range cases { + var result Container + if err := json.Unmarshal([]byte(c.input), &result); err != nil { + t.Errorf("Failed to unmarshal input '%v': %v", c.input, err) + } + if !reflect.DeepEqual(result, c.result) { + t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result) + } + } +} + +func TestContainerUnmarshalYAML(t *testing.T) { + cases := []struct { + input string + result Container + }{ + { + input: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +`, + result: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + }, + }, + { + input: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +readinessProbe: + probeHandler: + _type: Http + url: http://localhost:80 + initialDelaySeconds: 10 +`, + result: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +readinessProbe: + probeHandler: + _type: Exec + command: + - cat + - /tmp/healthy + initialDelaySeconds: 10 +`, + result: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Exec"}, + ExecAction: &ExecAction{ + Command: []string{"cat", "/tmp/healthy"}, + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +readinessProbe: + probeHandler: + _type: Tcp + url: 127.0.0.1:8080 + initialDelaySeconds: 10 +`, + result: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "Tcp"}, + TCPSocketAction: &TCPSocketAction{ + URL: "127.0.0.1:8080", + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +lifecycle: + preStop: + _type: Exec + command: + - /bin/sh + - -c + - echo Hello from the postStart handler > /usr/share/message + postStart: + _type: Exec + command: + - /bin/sh + - -c + - nginx -s quit; while killall -0 nginx; do sleep 1; done +`, + result: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, + }, + }, + }, + }, + }, + { + input: `image: nginx:v1 +command: +- /bin/sh +- -c +- echo hi +args: +- /bin/sh +- -c +- echo hi +env: + env1: VALUE +workingDir: /tmp +lifecycle: + preStop: + _type: Http + url: http://localhost:80 + postStart: + _type: Http + url: http://localhost:80 +`, + result: Container{ + Image: "nginx:v1", + Command: []string{"/bin/sh", "-c", "echo hi"}, + Args: []string{"/bin/sh", "-c", "echo hi"}, + Env: yaml.MapSlice{ + { + Key: "env1", + Value: "VALUE", + }, + }, + WorkingDir: "/tmp", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + }, + }, + }, + } + + for _, c := range cases { + var result Container + if err := yaml.Unmarshal([]byte(c.input), &result); err != nil { + t.Errorf("Failed to unmarshal input '%v': %v", c.input, err) + } + if !reflect.DeepEqual(result, c.result) { + t.Errorf("Failed to unmarshal input '%v': expected %+v, got %+v", c.input, c.result, result) + } + } +} + +func TestWorkloadUnmarshalJSON(t *testing.T) { + r1 := int32(1) + tests := []struct { + name string + data string + expected Workload + expectedError error + }{ + { + name: "Valid UnmarshalJSON for Service", + data: `{"_type": "Service", "replicas": 1, "labels": {}, "annotations": {}, "dirs": {}, "schedule": "* * * * *"}`, + expected: Workload{ + Header: Header{ + Type: TypeService, + }, + Service: &Service{ + Base: Base{ + Replicas: &r1, + Labels: map[string]string{}, + Annotations: map[string]string{}, + Dirs: map[string]string{}, + }, + }, + }, + expectedError: nil, + }, + { + name: "Valid UnmarshalJSON for Job", + data: `{"_type": "Job", "schedule": "* * * * *"}`, + expected: Workload{ + Header: Header{ + Type: TypeJob, + }, + Job: &Job{ + Schedule: "* * * * *", + }, + }, + expectedError: nil, + }, + { + name: "Unknown _Type", + data: `{"_type": "Unknown", "replicas": 1, "labels": {}, "annotations": {}, "dirs": {}, "schedule": "* * * * *"}`, + expected: Workload{ + Header: Header{ + Type: "Unknown", + }, + }, + expectedError: errors.New("unknown workload type"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var actual Workload + actualErr := json.Unmarshal([]byte(test.data), &actual) + if test.expectedError == nil { + assert.Equal(t, test.expected, actual) + assert.NoError(t, actualErr) + } else { + assert.ErrorContains(t, actualErr, test.expectedError.Error()) + } + }) + } +} + +func TestWorkloadUnmarshalYAML(t *testing.T) { + r1 := int32(1) + tests := []struct { + name string + data string + expected Workload + expectedError error + }{ + { + name: "Valid UnmarshalYAML for Service", + data: `_type: Service +replicas: 1 +labels: {} +annotations: {} +dirs: {} +schedule: '* * * * *'`, + expected: Workload{ + Header: Header{ + Type: TypeService, + }, + Service: &Service{ + Base: Base{ + Replicas: &r1, + Labels: map[string]string{}, + Annotations: map[string]string{}, + Dirs: map[string]string{}, + }, + }, + }, + expectedError: nil, + }, + { + name: "Valid UnmarshalYAML for Job", + data: `_type: Job +replicas: 1 +labels: {} +annotations: {} +dirs: {} +schedule: '* * * * *'`, + expected: Workload{ + Header: Header{ + Type: TypeJob, + }, + Job: &Job{ + Base: Base{ + Replicas: &r1, + Labels: map[string]string{}, + Annotations: map[string]string{}, + Dirs: map[string]string{}, + }, + Schedule: "* * * * *", + }, + }, + expectedError: nil, + }, + { + name: "Unknown _Type", + data: `_type: Unknown +replicas: 1 +labels: {} +annotations: {} +dirs: {} +schedule: '* * * * *'`, + expected: Workload{}, + expectedError: errors.New("unknown workload type"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var actual Workload + actualErr := yaml.Unmarshal([]byte(test.data), &actual) + if test.expectedError == nil { + assert.Equal(t, test.expected, actual) + assert.NoError(t, actualErr) + } else { + assert.ErrorContains(t, actualErr, test.expectedError.Error()) + } + }) + } +}