From b1f90fa1064d2c1d3ca1497ba7baa3a72815dd5d Mon Sep 17 00:00:00 2001 From: Dayuan Date: Tue, 20 Feb 2024 15:26:42 +0800 Subject: [PATCH] refactor: app configuration model definition --- .../v1/appconfiguration/appconfiguration.go | 80 ++ .../v1/appconfiguration/workload/common.go | 19 + .../workload/container/container.go | 354 ++++++++ .../workload/container/container_test.go | 791 ++++++++++++++++++ .../core/v1/appconfiguration/workload/job.go | 11 + .../appconfiguration/workload/network/port.go | 18 + .../v1/appconfiguration/workload/secret.go | 8 + .../v1/appconfiguration/workload/service.go | 23 + .../v1/appconfiguration/workload/workload.go | 117 +++ .../workload/workload_test.go | 309 +++++++ 10 files changed, 1730 insertions(+) create mode 100644 pkg/apis/core/v1/appconfiguration/appconfiguration.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/common.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/container/container.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/container/container_test.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/job.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/network/port.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/secret.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/service.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/workload.go create mode 100644 pkg/apis/core/v1/appconfiguration/workload/workload_test.go diff --git a/pkg/apis/core/v1/appconfiguration/appconfiguration.go b/pkg/apis/core/v1/appconfiguration/appconfiguration.go new file mode 100644 index 00000000..96b551a0 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/appconfiguration.go @@ -0,0 +1,80 @@ +package appconfiguration + +import ( + "kusionstack.io/kusion/pkg/modules/inputs/workload" +) + +type Accessory map[string]interface{} + +// AppConfiguration is a developer-centric definition that describes how to run an Application. 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 +// "mysql" : d.MySQL { +// type: "cloud" +// version: "8.0" +// } +// # Built-in module +// "pro" : m.Prometheus { +// path: "/metrics" +// } +// # Customized module +// "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 Application. + Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Workload defines how to run your application code. + Workload *workload.Workload `json:"workload" yaml:"workload"` + // Accessories defines a collection of accessories that will be attached to the workload. + 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/core/v1/appconfiguration/workload/common.go b/pkg/apis/core/v1/appconfiguration/workload/common.go new file mode 100644 index 00000000..2adef9fd --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/common.go @@ -0,0 +1,19 @@ +package workload + +import "kusionstack.io/kusion/pkg/modules/inputs/workload/container" + +// Base defines set of attributes shared by different workload profile, e.g. Service and Job. You can inherit this Schema to reuse these +// common attributes. +type Base struct { + // The templates of containers to be run. + Containers map[string]container.Container `yaml:"containers,omitempty" json:"containers,omitempty"` + // The number of containers that should be run. Default is 2 to meet high availability requirements. + Replicas int `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"` +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/container/container.go b/pkg/apis/core/v1/appconfiguration/workload/container/container.go new file mode 100644 index 00000000..198a3ef4 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/container/container.go @@ -0,0 +1,354 @@ +package container + +import ( + "encoding/json" + "errors" + + "gopkg.in/yaml.v2" +) + +// Container describes how the Application'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"` +} + +// 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") + } +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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") + } +} + +// 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 +} + +// 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") + } +} + +// 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 +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/container/container_test.go b/pkg/apis/core/v1/appconfiguration/workload/container/container_test.go new file mode 100644 index 00000000..4c51a421 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/container/container_test.go @@ -0,0 +1,791 @@ +package container + +import ( + "encoding/json" + "reflect" + "testing" + + "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 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 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 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) + } + } +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/job.go b/pkg/apis/core/v1/appconfiguration/workload/job.go new file mode 100644 index 00000000..252155d7 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/job.go @@ -0,0 +1,11 @@ +package workload + +const ModuleJob = "job" + +// 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"` +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/network/port.go b/pkg/apis/core/v1/appconfiguration/workload/network/port.go new file mode 100644 index 00000000..959fc4f8 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/network/port.go @@ -0,0 +1,18 @@ +package network + +type Protocol string + +const ( + TCP Protocol = "TCP" + UDP Protocol = "UDP" +) + +// Port defines the exposed port of workload.Service +type Port struct { + // Port is the exposed port of the workload.Service. + Port int `yaml:"port,omitempty" json:"port,omitempty"` + // TargetPort is the backend container.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"` +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/secret.go b/pkg/apis/core/v1/appconfiguration/workload/secret.go new file mode 100644 index 00000000..ab0eb506 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/secret.go @@ -0,0 +1,8 @@ +package workload + +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"` +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/service.go b/pkg/apis/core/v1/appconfiguration/workload/service.go new file mode 100644 index 00000000..a2100f5b --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/service.go @@ -0,0 +1,23 @@ +package workload + +import ( + "kusionstack.io/kusion/pkg/modules/inputs/workload/network" +) + +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 []network.Port `yaml:"ports,omitempty" json:"ports,omitempty"` +} diff --git a/pkg/apis/core/v1/appconfiguration/workload/workload.go b/pkg/apis/core/v1/appconfiguration/workload/workload.go new file mode 100644 index 00000000..63d22a21 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/workload.go @@ -0,0 +1,117 @@ +package workload + +import ( + "encoding/json" + "errors" +) + +type Type string + +const ( + TypeJob = "Job" + TypeService = "Service" +) + +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"` +} + +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") + } +} + +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) MarshalYAML() (interface{}, error) { + switch w.Header.Type { + case TypeService: + return struct { + Header `yaml:",inline" json:",inline"` + Service `yaml:",inline" json:",inline"` + }{ + Header: Header{w.Header.Type}, + Service: *w.Service, + }, nil + case TypeJob: + return struct { + Header `yaml:",inline" json:",inline"` + *Job `yaml:",inline" json:",inline"` + }{ + Header: Header{w.Header.Type}, + Job: w.Job, + }, nil + default: + return nil, errors.New("unknown workload type") + } +} + +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/core/v1/appconfiguration/workload/workload_test.go b/pkg/apis/core/v1/appconfiguration/workload/workload_test.go new file mode 100644 index 00000000..819d3699 --- /dev/null +++ b/pkg/apis/core/v1/appconfiguration/workload/workload_test.go @@ -0,0 +1,309 @@ +package workload + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestWorkload_MarshalJSON(t *testing.T) { + 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: 2, + 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 TestWorkload_UnmarshalJSON(t *testing.T) { + 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: 1, + 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 TestWorkload_MarshalYAML(t *testing.T) { + 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: 2, + 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: 2, + 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()) + } + }) + } +} + +func TestWorkload_UnmarshalYAML(t *testing.T) { + 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: 1, + 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: 1, + 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()) + } + }) + } +}