diff --git a/README.md b/README.md index be9835d..94d14ac 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Kusion is an Intent-Driven Platform Orchestrator that enables developers to specify their desired intent in a declarative way and then use a consistent workflow to drive continuous delivery through the entire application lifecycle. -To achieve that, we've introduced the concept of [Kusion Modules](https://www.kusionstack.io/docs/concepts/kusion-module/overview) for users to prescribe their intent in a structured way. Kusion Modules are modular building blocks that represent common and re-usable capabilities required during an application delivery. +To achieve that, we've introduced the concept of [Kusion Modules](https://www.kusionstack.io/docs/concepts/module/overview) for users to prescribe their intent in a structured way. Kusion Modules are modular building blocks that represent common and re-usable capabilities required during an application delivery. One of the core goals of Kusion is to build an open, inclusive and prosperous open-source community focused on solving real-world application delivery and management problems, in the meantime sharing the reusable building blocks and best practices. This repository contains the source code for all Kusion Modules that can be used publicly. If your module is open to the public, we **welcome and highly encourage** you to contribute it to this repository, so that more people can benefit from the module. Submit a pull request to this repository, once it is merged, it will be published to the [KusionStack GitHub container registry](https://github.com/orgs/KusionStack/packages). -We also provide a module [developer guide](https://www.kusionstack.io/docs/concepts/kusion-module/develop-guide) on our website, if you have any questions, please don't hesitate to contact us directly. +We also provide a module [developer guide](https://www.kusionstack.io/docs/concepts/module/develop-guide) on our website, if you have any questions, please don't hesitate to contact us directly. Some of the modules in this repository are maintained by the KusionStack team, representing our understanding of a "golden path" and are designed to be used out-of-the-box. All examples can be found in the [User Guide](https://www.kusionstack.io/docs/user-guides/working-with-k8s/deploy-application) on our website. @@ -43,7 +43,7 @@ The modules defined in the `catalog` repository are published to the [KusionStac 1. Please visit [module references](https://www.kusionstack.io/docs/reference/modules/) on the website or example/readme.md in each module directory to understand the capabilities and usage of each module. 2. Register this module in your workspace and set default values to standardize the module's behavior -Please visit the [platform engineer development guide](https://www.kusionstack.io/docs/concepts/kusion-module/develop-guide) for more details. +Please visit the [platform engineer development guide](https://www.kusionstack.io/docs/concepts/module/develop-guide) for more details. ### App Developers @@ -54,4 +54,4 @@ As an application developer, the workflow of using a Kusion module looks like th 3. Initialize modules 4. Apply the AppConfiguration -Please visit the [application developer user guide](https://www.kusionstack.io/docs/concepts/kusion-module/app-dev-guide) for more details. \ No newline at end of file +Please visit the [application developer user guide](https://www.kusionstack.io/docs/concepts/module/app-dev-guide) for more details. \ No newline at end of file diff --git a/modules/workload/job/src/Makefile b/modules/workload/job/src/Makefile new file mode 100644 index 0000000..7d45b08 --- /dev/null +++ b/modules/workload/job/src/Makefile @@ -0,0 +1,36 @@ +TEST?=$$(go list ./... | grep -v 'vendor') +###### chang variables below according to your own modules ### +NAMESPACE=kusionstack +NAME=job +VERSION=0.1.0 +BINARY=../bin/kusion-module-${NAME}_${VERSION} + +LOCAL_ARCH := $(shell uname -m) +ifeq ($(LOCAL_ARCH),x86_64) +GOARCH_LOCAL := amd64 +else +GOARCH_LOCAL := $(LOCAL_ARCH) +endif +export GOOS_LOCAL := $(shell uname|tr 'A-Z' 'a-z') +export OS_ARCH ?= $(GOARCH_LOCAL) + +default: install + +build-darwin: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY} + +install: build-darwin +# copy module binary to $KUSION_HOME. e.g. ~/.kusion/modules/kusionstack/job/v0.1.0/darwin/arm64/kusion-module-service_0.1.0 + mkdir -p ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + cp ${BINARY} ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + +release: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY}_darwin_arm64 ./${NAME} + GOOS=darwin GOARCH=amd64 go build -o ${BINARY}_darwin_amd64 ./${NAME} + GOOS=linux GOARCH=arm64 go build -o ${BINARY}_linux_arm64 ./${NAME} + GOOS=linux GOARCH=amd64 go build -o ${BINARY}_linux_amd64 ./${NAME} + GOOS=windows GOARCH=amd64 go build -o ${BINARY}_windows_amd64 ./${NAME} + GOOS=windows GOARCH=386 go build -o ${BINARY}_windows_386 ./${NAME} + +test: + TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 5m diff --git a/modules/workload/job/src/go.mod b/modules/workload/job/src/go.mod new file mode 100644 index 0000000..ab7c2f9 --- /dev/null +++ b/modules/workload/job/src/go.mod @@ -0,0 +1,50 @@ +module job + +go 1.22.1 + +require ( + github.com/imdario/mergo v0.3.16 + github.com/stretchr/testify v1.9.0 + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 + gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.30.0 + k8s.io/apimachinery v0.30.0 + kusionstack.io/kusion v0.12.0 + kusionstack.io/kusion-module-framework v0.2.2 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/hashicorp/go-hclog v1.2.0 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/modules/workload/job/src/job.go b/modules/workload/job/src/job.go new file mode 100644 index 0000000..63ab270 --- /dev/null +++ b/modules/workload/job/src/job.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + + "gopkg.in/yaml.v2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion-module-framework/pkg/server" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/modules" +) + +func (j *Job) Generate(_ context.Context, request *module.GeneratorRequest) (*module.GeneratorResponse, error) { + defer func() { + if r := recover(); r != nil { + log.Debugf("failed to generate Job module: %v", r) + } + }() + + if request.DevConfig == nil { + log.Info("Job does not exist in AppConfig config") + return nil, nil + } + out, err := yaml.Marshal(request.DevConfig) + if err != nil { + return nil, err + } + + if err = yaml.Unmarshal(out, j); err != nil { + return nil, fmt.Errorf("complete Job by dev config failed, %w", err) + } + + if err = completeBaseWorkload(&j.Base, request.PlatformConfig); err != nil { + return nil, fmt.Errorf("complete Job by platform config failed, %w", err) + } + + uniqueAppName := modules.UniqueAppName(request.Project, request.Stack, request.App) + + meta := metav1.ObjectMeta{ + Namespace: request.Project, + Name: uniqueAppName, + Labels: modules.MergeMaps( + modules.UniqueAppLabels(request.Project, request.App), + j.Labels, + ), + Annotations: modules.MergeMaps( + j.Annotations, + ), + } + + containers, volumes, configMaps, err := toOrderedContainers(j.Containers, uniqueAppName) + if err != nil { + return nil, err + } + + res := make([]v1.Resource, 0) + for _, cm := range configMaps { + cm.Namespace = request.Project + resourceID := module.KubernetesResourceID(cm.TypeMeta, cm.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, &cm) + if err != nil { + return nil, err + } + res = append(res, *resource) + } + + jobSpec := batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: modules.MergeMaps(modules.UniqueAppLabels(request.Project, request.App), j.Labels), + Annotations: modules.MergeMaps(j.Annotations), + }, + Spec: corev1.PodSpec{ + Containers: containers, + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + }, + }, + } + + if j.Schedule == "" { + k8sJob := &batchv1.Job{ + ObjectMeta: meta, + TypeMeta: metav1.TypeMeta{ + Kind: "Job", + APIVersion: batchv1.SchemeGroupVersion.String(), + }, + Spec: jobSpec, + } + + resourceID := module.KubernetesResourceID(k8sJob.TypeMeta, k8sJob.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, k8sJob) + if err != nil { + return nil, err + } + res = append(res, *resource) + + return &module.GeneratorResponse{ + Resources: res, + }, nil + } + + cronJob := &batchv1.CronJob{ + ObjectMeta: meta, + TypeMeta: metav1.TypeMeta{ + Kind: "CronJob", + APIVersion: batchv1.SchemeGroupVersion.String(), + }, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: jobSpec, + }, + Schedule: j.Schedule, + }, + } + + resourceID := module.KubernetesResourceID(cronJob.TypeMeta, cronJob.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, cronJob) + if err != nil { + return nil, err + } + res = append(res, *resource) + return &module.GeneratorResponse{ + Resources: res, + }, nil +} + +func main() { + server.Start(&Job{}) +} diff --git a/modules/workload/job/src/job_test.go b/modules/workload/job/src/job_test.go new file mode 100644 index 0000000..17a2b6e --- /dev/null +++ b/modules/workload/job/src/job_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "service" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/modules" +) + +func TestNewJobGenerator(t *testing.T) { + expectedProject := "test" + expectedStack := "dev" + expectedAppName := "test" + expectedJob := &v1.Job{} + expectedJobConfig := v1.GenericConfig{ + "labels": v1.GenericConfig{ + "Workload-type": "Job", + }, + "annotations": v1.GenericConfig{ + "Workload-type": "Job", + }, + } + actual, err := NewJobGenerator(&Generator{ + Project: expectedProject, + Stack: expectedStack, + App: expectedAppName, + Namespace: expectedAppName, + service.Workload: &v1.Workload{ + Job: expectedJob, + }, + PlatformConfigs: map[string]v1.GenericConfig{ + v1.ModuleJob: expectedJobConfig, + }, + }) + + assert.NoError(t, err, "Error should be nil") + assert.NotNil(t, actual, "Generator should not be nil") + assert.Equal(t, expectedProject, actual.(*jobGenerator).project, "Project mismatch") + assert.Equal(t, expectedStack, actual.(*jobGenerator).stack, "Stack mismatch") + assert.Equal(t, expectedAppName, actual.(*jobGenerator).appName, "AppName mismatch") + assert.Equal(t, expectedJob, actual.(*jobGenerator).job, "Job mismatch") + assert.Equal(t, expectedJobConfig, actual.(*jobGenerator).jobConfig, "JobConfig mismatch") +} + +func TestNewJobGeneratorFunc(t *testing.T) { + expectedProject := "test" + expectedStack := "dev" + expectedAppName := "test" + expectedJob := &v1.Job{} + expectedJobConfig := v1.GenericConfig{ + "labels": v1.GenericConfig{ + "workload-type": "Job", + }, + "annotations": v1.GenericConfig{ + "workload-type": "Job", + }, + } + generatorFunc := NewJobGeneratorFunc(&Generator{ + Project: expectedProject, + Stack: expectedStack, + App: expectedAppName, + Namespace: expectedAppName, + service.Workload: &v1.Workload{ + Job: expectedJob, + }, + PlatformConfigs: map[string]v1.GenericConfig{ + v1.ModuleJob: expectedJobConfig, + }, + }) + actualGenerator, err := generatorFunc() + + assert.NoError(t, err, "Error should be nil") + assert.NotNil(t, actualGenerator, "Generator should not be nil") + assert.Equal(t, expectedProject, actualGenerator.(*jobGenerator).project, "Project mismatch") + assert.Equal(t, expectedStack, actualGenerator.(*jobGenerator).stack, "Stack mismatch") + assert.Equal(t, expectedAppName, actualGenerator.(*jobGenerator).appName, "AppName mismatch") + assert.Equal(t, expectedJob, actualGenerator.(*jobGenerator).job, "Job mismatch") + assert.Equal(t, expectedJobConfig, actualGenerator.(*jobGenerator).jobConfig, "JobConfig mismatch") +} + +func TestJobGenerator_Generate(t *testing.T) { + testCases := []struct { + name string + expectedProject string + expectedStack string + expectedAppName string + expectedJob *v1.Job + expectedJobConfig v1.GenericConfig + }{ + { + name: "test generate", + expectedProject: "test", + expectedStack: "dev", + expectedAppName: "test", + expectedJob: &v1.Job{}, + expectedJobConfig: v1.GenericConfig{ + "labels": v1.GenericConfig{ + "workload-type": "Job", + }, + "annotations": v1.GenericConfig{ + "workload-type": "Job", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + generator, _ := NewJobGenerator(&Generator{ + Project: tc.expectedProject, + Stack: tc.expectedStack, + App: tc.expectedAppName, + Namespace: tc.expectedAppName, + service.Workload: &v1.Workload{ + Job: tc.expectedJob, + }, + PlatformConfigs: map[string]v1.GenericConfig{ + v1.ModuleJob: tc.expectedJobConfig, + }, + }) + spec := &v1.Spec{} + err := generator.Generate(spec) + + assert.NoError(t, err, "Error should be nil") + assert.NotNil(t, spec.Resources, "Resources should not be nil") + assert.Len(t, spec.Resources, 1, "Number of resources mismatch") + + // Check the generated resource + resource := spec.Resources[0] + actual := mapToUnstructured(resource.Attributes) + + assert.Equal(t, "Job", actual.GetKind(), "Kind mismatch") + assert.Equal(t, tc.expectedProject, actual.GetNamespace(), "Namespace mismatch") + assert.Equal(t, modules.UniqueAppName(tc.expectedProject, tc.expectedStack, tc.expectedAppName), actual.GetName(), "Name mismatch") + assert.Equal(t, modules.MergeMaps(modules.UniqueAppLabels(tc.expectedProject, tc.expectedAppName), tc.expectedJob.Labels), actual.GetLabels(), "Labels mismatch") + assert.Equal(t, modules.MergeMaps(tc.expectedJob.Annotations), actual.GetAnnotations(), "Annotations mismatch") + }) + } +} + +func mapToUnstructured(data map[string]interface{}) *unstructured.Unstructured { + unstructuredObj := &unstructured.Unstructured{} + unstructuredObj.SetUnstructuredContent(data) + return unstructuredObj +} diff --git a/modules/workload/job/src/type.go b/modules/workload/job/src/type.go new file mode 100644 index 0000000..9ce8e53 --- /dev/null +++ b/modules/workload/job/src/type.go @@ -0,0 +1,180 @@ +package main + +import "gopkg.in/yaml.v2" + +// 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"` +} + +const ( + BuiltinModulePrefix = "" + ProbePrefix = "service.container.probe." + TypeHTTP = BuiltinModulePrefix + ProbePrefix + "Http" + TypeExec = BuiltinModulePrefix + ProbePrefix + "Exec" + TypeTCP = BuiltinModulePrefix + ProbePrefix + "Tcp" +) + +// 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" +) + +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"` +} + +const ( + FieldLabels = "labels" + FieldAnnotations = "annotations" + FieldReplicas = "replicas" +) + +// 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"` +} diff --git a/modules/workload/job/src/workload_base.go b/modules/workload/job/src/workload_base.go new file mode 100644 index 0000000..d05e6f4 --- /dev/null +++ b/modules/workload/job/src/workload_base.go @@ -0,0 +1,544 @@ +package main + +import ( + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/imdario/mergo" + "golang.org/x/exp/maps" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + kusionv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/modules" + "kusionstack.io/kusion/pkg/util/net" + "kusionstack.io/kusion/pkg/workspace" +) + +func toOrderedContainers( + appContainers map[string]Container, + uniqueAppName string, +) ([]corev1.Container, []corev1.Volume, []corev1.ConfigMap, error) { + // Create a slice of containers based on the App's containers. + var containers []corev1.Container + + // Create a slice of volumes and configMaps based on the containers' files to be created. + var volumes []corev1.Volume + var configMaps []corev1.ConfigMap + + if err := modules.ForeachOrdered(appContainers, func(containerName string, c Container) error { + // Create a slice of env vars based on the container's env vars. + var envs []corev1.EnvVar + for _, m := range c.Env { + envs = append(envs, *MagicEnvVar(m.Key.(string), m.Value.(string))) + } + + resourceRequirements, err := handleResourceRequirementsV1(c.Resources) + if err != nil { + return err + } + + // Create a container object. + ctn := corev1.Container{ + Name: containerName, + Image: c.Image, + Command: c.Command, + Args: c.Args, + WorkingDir: c.WorkingDir, + Env: envs, + Resources: resourceRequirements, + } + if err = updateContainer(&c, &ctn); err != nil { + return err + } + + // Append the configMap, volume and volumeMount objects into the corresponding slices. + volumesContainer, volumeMounts, configMapsContainer, err := handleFileCreation(c, uniqueAppName, containerName) + if err != nil { + return err + } + volumes = append(volumes, volumesContainer...) + configMaps = append(configMaps, configMapsContainer...) + ctn.VolumeMounts = append(ctn.VolumeMounts, volumeMounts...) + + // Append more volumes and volumeMounts + otherVolumes, otherVolumeMounts, err := handleDirCreation(c) + if err != nil { + return err + } + volumes = append(volumes, otherVolumes...) + ctn.VolumeMounts = append(ctn.VolumeMounts, otherVolumeMounts...) + + // Append the container object to the containers slice. + containers = append(containers, ctn) + return nil + }); err != nil { + return nil, nil, nil, err + } + return containers, volumes, configMaps, nil +} + +// updateContainer updates corev1.Container with passed parameters. +func updateContainer(in *Container, out *corev1.Container) error { + if in.ReadinessProbe != nil { + readinessProbe, err := convertKusionProbeToV1Probe(in.ReadinessProbe) + if err != nil { + return err + } + out.ReadinessProbe = readinessProbe + } + + if in.LivenessProbe != nil { + livenessProbe, err := convertKusionProbeToV1Probe(in.LivenessProbe) + if err != nil { + return err + } + out.LivenessProbe = livenessProbe + } + + if in.StartupProbe != nil { + startupProbe, err := convertKusionProbeToV1Probe(in.StartupProbe) + if err != nil { + return err + } + out.StartupProbe = startupProbe + } + + if in.Lifecycle != nil { + lifecycle, err := convertKusionLifecycleToV1Lifecycle(in.Lifecycle) + if err != nil { + return err + } + out.Lifecycle = lifecycle + } + + return nil +} + +// handleResourceRequirementsV1 parses the resources parameter if specified and +// returns ResourceRequirements. +func handleResourceRequirementsV1(resources map[string]string) (corev1.ResourceRequirements, error) { + result := corev1.ResourceRequirements{} + if resources == nil { + return result, nil + } + for key, value := range resources { + resourceName := corev1.ResourceName(key) + requests, limits, err := populateResourceLists(resourceName, value) + if err != nil { + return result, err + } + if requests != nil && result.Requests == nil { + result.Requests = make(corev1.ResourceList) + } + maps.Copy(result.Requests, requests) + if limits != nil && result.Limits == nil { + result.Limits = make(corev1.ResourceList) + } + maps.Copy(result.Limits, limits) + } + return result, nil +} + +// populateResourceLists takes strings of form =[-] and +// returns request&limit ResourceList. +func populateResourceLists(name corev1.ResourceName, spec string) (corev1.ResourceList, corev1.ResourceList, error) { + requests := corev1.ResourceList{} + limits := corev1.ResourceList{} + + parts := strings.Split(spec, "-") + if len(parts) == 1 { + resourceQuantity, err := resource.ParseQuantity(parts[0]) + if err != nil { + return nil, nil, err + } + limits[name] = resourceQuantity + } else if len(parts) == 2 { + resourceQuantity, err := resource.ParseQuantity(parts[0]) + if err != nil { + return nil, nil, err + } + requests[name] = resourceQuantity + resourceQuantity, err = resource.ParseQuantity(parts[1]) + if err != nil { + return nil, nil, err + } + limits[name] = resourceQuantity + } + + return requests, limits, nil +} + +// convertKusionProbeToV1Probe converts Kusion Probe to Kubernetes Probe types. +func convertKusionProbeToV1Probe(p *Probe) (*corev1.Probe, error) { + result := &corev1.Probe{ + InitialDelaySeconds: p.InitialDelaySeconds, + TimeoutSeconds: p.TimeoutSeconds, + PeriodSeconds: p.PeriodSeconds, + SuccessThreshold: p.SuccessThreshold, + FailureThreshold: p.FailureThreshold, + } + probeHandler := p.ProbeHandler + switch probeHandler.Type { + case TypeHTTP: + action, err := httpGetAction(probeHandler.HTTPGetAction.URL, probeHandler.Headers) + if err != nil { + return nil, err + } + result.HTTPGet = action + case TypeExec: + result.Exec = &corev1.ExecAction{Command: probeHandler.Command} + case TypeTCP: + action, err := tcpSocketAction(probeHandler.TCPSocketAction.URL) + if err != nil { + return nil, err + } + result.TCPSocket = action + } + return result, nil +} + +// convertKusionLifecycleToV1Lifecycle converts Kusion Lifecycle to Kubernetes Lifecycle types. +func convertKusionLifecycleToV1Lifecycle(l *Lifecycle) (*corev1.Lifecycle, error) { + result := &corev1.Lifecycle{} + if l.PreStop != nil { + preStop, err := lifecycleHandler(l.PreStop) + if err != nil { + return nil, err + } + result.PreStop = preStop + } + if l.PostStart != nil { + postStart, err := lifecycleHandler(l.PostStart) + if err != nil { + return nil, err + } + result.PostStart = postStart + } + return result, nil +} + +func lifecycleHandler(in *LifecycleHandler) (*corev1.LifecycleHandler, error) { + result := &corev1.LifecycleHandler{} + switch in.Type { + case TypeHTTP: + action, err := httpGetAction(in.HTTPGetAction.URL, in.Headers) + if err != nil { + return nil, err + } + result.HTTPGet = action + case TypeExec: + result.Exec = &corev1.ExecAction{Command: in.Command} + } + return result, nil +} + +func httpGetAction(urlstr string, headers map[string]string) (*corev1.HTTPGetAction, error) { + u, err := url.Parse(urlstr) + if err != nil { + return nil, err + } + + httpHeaders := make([]corev1.HTTPHeader, 0, len(headers)) + for k, v := range headers { + httpHeaders = append(httpHeaders, corev1.HTTPHeader{ + Name: k, + Value: v, + }) + } + + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" { + host = "" + } + + return &corev1.HTTPGetAction{ + Path: u.Path, + Port: intstr.Parse(u.Port()), + Host: host, + Scheme: corev1.URIScheme(strings.ToUpper(u.Scheme)), + HTTPHeaders: httpHeaders, + }, nil +} + +func tcpSocketAction(urlstr string) (*corev1.TCPSocketAction, error) { + host, port, err := net.ParseHostPort(urlstr) + if err != nil { + return nil, err + } + + return &corev1.TCPSocketAction{ + Port: intstr.Parse(port), + Host: host, + }, nil +} + +// handleFileCreation handles the creation of the files declared in container.Files +// and returns the generated ConfigMap, Volume and VolumeMount. +func handleFileCreation(c Container, uniqueAppName, containerName string) ( + volumes []corev1.Volume, + volumeMounts []corev1.VolumeMount, + configMaps []corev1.ConfigMap, + err error, +) { + var idx int + err = modules.ForeachOrdered(c.Files, func(k string, v FileSpec) error { + // The declared file path needs to include the file name. + if filepath.Base(k) == "." || filepath.Base(k) == "/" { + return fmt.Errorf("the declared file path needs to include the file name") + } + + // Specify the name of the configMap and volume to be created. + configMapName := uniqueAppName + "-" + containerName + "-" + strconv.Itoa(idx) + idx++ + + // Change the mode attribute from string into int32. + var modeInt32 int32 + if modeInt64, err2 := strconv.ParseInt(v.Mode, 0, 64); err2 != nil { + return err2 + } else { + modeInt32 = int32(modeInt64) + } + + if v.ContentFrom != "" { + sec, ok, parseErr := parseSecretReference(v.ContentFrom) + if parseErr != nil || !ok { + return fmt.Errorf("invalid content from str") + } + + volumes = append(volumes, corev1.Volume{ + Name: sec.Name, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: sec.Name, + DefaultMode: &modeInt32, + }, + }, + }) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: sec.Name, + MountPath: filepath.Join("/", k), + SubPath: sec.Key, + }) + } else if v.Content != "" { + // Create the file content with configMap. + data := make(map[string]string) + data[filepath.Base(k)] = v.Content + + configMaps = append(configMaps, corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + }, + Data: data, + }) + + volumes = append(volumes, corev1.Volume{ + Name: configMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + DefaultMode: &modeInt32, + }, + }, + }) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configMapName, + MountPath: filepath.Dir(k), + }) + } + return nil + }) + return +} + +// handleDirCreation handles the creation of folder declared in container.Dirs and returns +// the generated Volume and VolumeMount. +func handleDirCreation(c Container) (volumes []corev1.Volume, volumeMounts []corev1.VolumeMount, err error) { + err = modules.ForeachOrdered(c.Dirs, func(mountPath string, v string) error { + sec, ok, parseErr := parseSecretReference(v) + if parseErr != nil || !ok { + return fmt.Errorf("invalid dir configuration") + } + + volumes = append(volumes, corev1.Volume{ + Name: sec.Name, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: sec.Name, + }, + }, + }) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: sec.Name, + MountPath: filepath.Join("/", mountPath), + }) + return nil + }) + return +} + +// completeBaseWorkload uses config from workspace to complete the Workload base config. +func completeBaseWorkload(base *Base, config kusionv1.GenericConfig) error { + replicas, err := workspace.GetInt32PointerFromGenericConfig(config, FieldReplicas) + if err != nil { + return err + } + + // override the base replicas with the value from workspace if it is null + if base.Replicas == nil { + base.Replicas = replicas + } + labels, err := workspace.GetStringMapFromGenericConfig(config, FieldLabels) + if err != nil { + return err + } + if labels != nil { + if err = mergo.Merge(&base.Labels, labels); err != nil { + return err + } + } + annotations, err := workspace.GetStringMapFromGenericConfig(config, FieldAnnotations) + if err != nil { + return err + } + if annotations != nil { + if err = mergo.Merge(&base.Annotations, annotations); err != nil { + return err + } + } + return nil +} + +type secretReference struct { + Name string + Key string +} + +// parseSecretReference takes secret reference string as parameter and returns secretReference obj. +// Parameter `ref` is expected in following format: secret://sec-name/key, if the provided ref str +// is not in valid format, this function will return false or err. +func parseSecretReference(ref string) (result secretReference, _ bool, _ error) { + if strings.HasPrefix(ref, "${secret://") && strings.HasSuffix(ref, "}") { + ref = ref[2 : len(ref)-1] + } + + if !strings.HasPrefix(ref, "secret://") { + return result, false, nil + } + + u, err := url.Parse(ref) + if err != nil { + return result, false, err + } + + result.Name = u.Host + result.Key, _, _ = strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") + + return result, true, nil +} + +var ( + SecretEnvParser = NewSecretEnvParser() + RawEnvParser = NewRawEnvParser() + + supportedParsers = []MagicEnvParser{ + SecretEnvParser, + // As the default parser, the RawEnvParser should be placed at + // the end. + RawEnvParser, + } +) + +// MagicEnvVar generates a specialized EnvVar based on the key and +// value of environment. +// +// Examples: +// +// MagicEnvVar("secret_key", "secret://my_secret/my_key") +// MagicEnvVar("key", "value") +func MagicEnvVar(k, v string) *corev1.EnvVar { + for _, p := range supportedParsers { + if p.Match(k, v) { + return p.Gen(k, v) + } + } + return nil +} + +// MagicEnvParser is an interface for environment variable parsers. +type MagicEnvParser interface { + Match(k, v string) (matched bool) + Gen(k, v string) *corev1.EnvVar +} + +// rawEnvParser is a parser for raw environment variables. +type rawEnvParser struct{} + +// NewRawEnvParser creates a new instance of RawEnvParser. +func NewRawEnvParser() MagicEnvParser { + return &rawEnvParser{} +} + +// Match checks if the value matches the raw parser. +func (*rawEnvParser) Match(_ string, _ string) bool { + return true +} + +// Gen generates a raw environment variable. +func (*rawEnvParser) Gen(k string, v string) *corev1.EnvVar { + return &corev1.EnvVar{ + Name: k, + Value: v, + } +} + +// secretEnvParser is a parser for secret-based environment variables. +type secretEnvParser struct { + prefix string +} + +// NewSecretEnvParser creates a new instance of SecretEnvParser. +func NewSecretEnvParser() MagicEnvParser { + return &secretEnvParser{ + prefix: "secret://", + } +} + +// Match checks if the value matches the secret parser. +func (p *secretEnvParser) Match(_ string, v string) bool { + return strings.HasPrefix(v, p.prefix) +} + +// Gen generates a secret-based environment variable. +func (p *secretEnvParser) Gen(k string, v string) *corev1.EnvVar { + vv := strings.TrimPrefix(v, p.prefix) + vs := strings.Split(vv, "/") + if len(vs) != 2 { + return nil + } + + return &corev1.EnvVar{ + Name: k, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: vs[0], + }, + Key: vs[1], + }, + }, + } +} diff --git a/modules/workload/service/src/Makefile b/modules/workload/service/src/Makefile new file mode 100644 index 0000000..d824318 --- /dev/null +++ b/modules/workload/service/src/Makefile @@ -0,0 +1,36 @@ +TEST?=$$(go list ./... | grep -v 'vendor') +###### chang variables below according to your own modules ### +NAMESPACE=kusionstack +NAME=service +VERSION=0.1.0 +BINARY=../bin/kusion-module-${NAME}_${VERSION} + +LOCAL_ARCH := $(shell uname -m) +ifeq ($(LOCAL_ARCH),x86_64) +GOARCH_LOCAL := amd64 +else +GOARCH_LOCAL := $(LOCAL_ARCH) +endif +export GOOS_LOCAL := $(shell uname|tr 'A-Z' 'a-z') +export OS_ARCH ?= $(GOARCH_LOCAL) + +default: install + +build-darwin: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY} + +install: build-darwin +# copy module binary to $KUSION_HOME. e.g. ~/.kusion/modules/kusionstack/service/v0.1.0/darwin/arm64/kusion-module-service_0.1.0 + mkdir -p ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + cp ${BINARY} ${KUSION_HOME}/modules/${NAMESPACE}/${NAME}/${VERSION}/${GOOS_LOCAL}/${OS_ARCH} + +release: + GOOS=darwin GOARCH=arm64 go build -o ${BINARY}_darwin_arm64 ./${NAME} + GOOS=darwin GOARCH=amd64 go build -o ${BINARY}_darwin_amd64 ./${NAME} + GOOS=linux GOARCH=arm64 go build -o ${BINARY}_linux_arm64 ./${NAME} + GOOS=linux GOARCH=amd64 go build -o ${BINARY}_linux_amd64 ./${NAME} + GOOS=windows GOARCH=amd64 go build -o ${BINARY}_windows_amd64 ./${NAME} + GOOS=windows GOARCH=386 go build -o ${BINARY}_windows_386 ./${NAME} + +test: + TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 5m diff --git a/modules/workload/service/src/go.mod b/modules/workload/service/src/go.mod new file mode 100644 index 0000000..f18a1cc --- /dev/null +++ b/modules/workload/service/src/go.mod @@ -0,0 +1,118 @@ +module service + +go 1.22.1 + +require ( + github.com/imdario/mergo v0.3.16 + github.com/stretchr/testify v1.9.0 + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 + gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.30.0 + k8s.io/apimachinery v0.30.0 + kusionstack.io/kube-api v0.3.0 + kusionstack.io/kusion v0.12.0 + kusionstack.io/kusion-module-framework v0.2.2 +) + +require ( + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.29 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect + github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect + github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect + github.com/Azure/go-autorest/logger v0.2.1 // indirect + github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/alibabacloud-go/darabonba-array v0.1.0 // indirect + github.com/alibabacloud-go/darabonba-encode-util v0.0.2 // indirect + github.com/alibabacloud-go/darabonba-map v0.0.2 // indirect + github.com/alibabacloud-go/darabonba-string v1.0.2 // indirect + github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect + github.com/alibabacloud-go/openapi-util v0.1.0 // indirect + github.com/alibabacloud-go/tea v1.2.1 // indirect + github.com/alibabacloud-go/tea-utils v1.3.1 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.3 // indirect + github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect + github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 // indirect + github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 // indirect + github.com/aliyun/aliyun-secretsmanager-client-go v1.1.4 // indirect + github.com/aws/aws-sdk-go-v2 v1.23.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.25.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.16.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.5 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.17.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.20.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.25.6 // indirect + github.com/aws/smithy-go v1.17.0 // indirect + github.com/cenkalti/backoff/v3 v3.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set v1.7.1 // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.2 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.2.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/vault/api v1.10.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.66.2 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/controller-runtime v0.15.1 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/modules/workload/service/src/magic_env_var.go b/modules/workload/service/src/magic_env_var.go new file mode 100644 index 0000000..180311e --- /dev/null +++ b/modules/workload/service/src/magic_env_var.go @@ -0,0 +1,100 @@ +package main + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" +) + +var ( + SecretEnvParser = NewSecretEnvParser() + RawEnvParser = NewRawEnvParser() + + supportedParsers = []MagicEnvParser{ + SecretEnvParser, + // As the default parser, the RawEnvParser should be placed at + // the end. + RawEnvParser, + } +) + +// MagicEnvVar generates a specialized EnvVar based on the key and +// value of environment. +// +// Examples: +// +// MagicEnvVar("secret_key", "secret://my_secret/my_key") +// MagicEnvVar("key", "value") +func MagicEnvVar(k, v string) *corev1.EnvVar { + for _, p := range supportedParsers { + if p.Match(k, v) { + return p.Gen(k, v) + } + } + return nil +} + +// MagicEnvParser is an interface for environment variable parsers. +type MagicEnvParser interface { + Match(k, v string) (matched bool) + Gen(k, v string) *corev1.EnvVar +} + +// rawEnvParser is a parser for raw environment variables. +type rawEnvParser struct{} + +// NewRawEnvParser creates a new instance of RawEnvParser. +func NewRawEnvParser() MagicEnvParser { + return &rawEnvParser{} +} + +// Match checks if the value matches the raw parser. +func (*rawEnvParser) Match(_ string, _ string) bool { + return true +} + +// Gen generates a raw environment variable. +func (*rawEnvParser) Gen(k string, v string) *corev1.EnvVar { + return &corev1.EnvVar{ + Name: k, + Value: v, + } +} + +// secretEnvParser is a parser for secret-based environment variables. +type secretEnvParser struct { + prefix string +} + +// NewSecretEnvParser creates a new instance of SecretEnvParser. +func NewSecretEnvParser() MagicEnvParser { + return &secretEnvParser{ + prefix: "secret://", + } +} + +// Match checks if the value matches the secret parser. +func (p *secretEnvParser) Match(_ string, v string) bool { + return strings.HasPrefix(v, p.prefix) +} + +// Gen generates a secret-based environment variable. +func (p *secretEnvParser) Gen(k string, v string) *corev1.EnvVar { + vv := strings.TrimPrefix(v, p.prefix) + vs := strings.Split(vv, "/") + if len(vs) != 2 { + return nil + } + + return &corev1.EnvVar{ + Name: k, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: vs[0], + }, + Key: vs[1], + }, + }, + } +} diff --git a/modules/workload/service/src/magic_env_var_test.go b/modules/workload/service/src/magic_env_var_test.go new file mode 100644 index 0000000..576933a --- /dev/null +++ b/modules/workload/service/src/magic_env_var_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMagicEnvVar(t *testing.T) { + // Test raw environment variable + rawEnv := MagicEnvVar("key", "value") + assert.NotNil(t, rawEnv, "Raw environment variable should not be nil") + assert.Equal(t, "key", rawEnv.Name, "Expected raw environment variable name to be 'key'") + assert.Equal(t, "value", rawEnv.Value, "Expected raw environment variable value to be 'value'") + + // Test secret-based environment variable + secretEnv := MagicEnvVar("secret_key", "secret://my_secret/my_key") + assert.NotNil(t, secretEnv, "Secret-based environment variable should not be nil") + assert.Equal(t, "secret_key", secretEnv.Name, "Expected secret-based environment variable name to be 'secret_key'") + assert.Equal(t, "my_secret", secretEnv.ValueFrom.SecretKeyRef.LocalObjectReference.Name, "Expected secret name to be 'my_secret'") + assert.Equal(t, "my_key", secretEnv.ValueFrom.SecretKeyRef.Key, "Expected secret key to be 'my_key'") +} diff --git a/modules/workload/service/src/marshal.go b/modules/workload/service/src/marshal.go new file mode 100644 index 0000000..eab43a2 --- /dev/null +++ b/modules/workload/service/src/marshal.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "errors" +) + +// MarshalJSON implements the json.Marshaler interface for ProbeHandler. +func (p *ProbeHandler) MarshalJSON() ([]byte, error) { + switch p.Type { + case TypeHTTP: + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *HTTPGetAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{p.Type}, + HTTPGetAction: p.HTTPGetAction, + }) + case TypeExec: + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *ExecAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{p.Type}, + ExecAction: p.ExecAction, + }) + case TypeTCP: + 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 TypeHTTP: + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + HTTPGetAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: p.Type}, + HTTPGetAction: *p.HTTPGetAction, + }, nil + case TypeExec: + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + ExecAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: p.Type}, + ExecAction: *p.ExecAction, + }, nil + case TypeTCP: + 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 TypeHTTP: + return json.Marshal(struct { + TypeWrapper `json:",inline"` + *HTTPGetAction `json:",inline"` + }{ + TypeWrapper: TypeWrapper{l.Type}, + HTTPGetAction: l.HTTPGetAction, + }) + case TypeExec: + 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 TypeHTTP: + return struct { + TypeWrapper `yaml:",inline" json:",inline"` + HTTPGetAction `yaml:",inline" json:",inline"` + }{ + TypeWrapper: TypeWrapper{Type: l.Type}, + HTTPGetAction: *l.HTTPGetAction, + }, nil + case TypeExec: + 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") + } +} diff --git a/modules/workload/service/src/marshal_test.go b/modules/workload/service/src/marshal_test.go new file mode 100644 index 0000000..8353812 --- /dev/null +++ b/modules/workload/service/src/marshal_test.go @@ -0,0 +1,399 @@ +package main + +import ( + "encoding/json" + "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{"service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"service.container.probe.Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, + }, + { + input: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"cat", "/tmp/healthy"}, + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"service.container.probe.Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, + }, + { + input: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "service.container.probe.Tcp"}, + TCPSocketAction: &TCPSocketAction{ + URL: "127.0.0.1:8080", + }, + }, + InitialDelaySeconds: 10, + }, + }, + result: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"service.container.probe.Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, + }, + { + input: Container{ + Image: "nginx:v1", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, + }, + }, + }, + }, + result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"service.container.probe.Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"service.container.probe.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{"service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + }, + }, + result: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"service.container.probe.Http","url":"http://localhost:80"},"postStart":{"_type":"service.container.probe.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: "service.container.probe.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: service.container.probe.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: "service.container.probe.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: service.container.probe.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: "service.container.probe.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: service.container.probe.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{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.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: service.container.probe.Exec + command: + - /bin/sh + - -c + - echo Hello from the postStart handler > /usr/share/message + postStart: + _type: service.container.probe.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{"service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.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: service.container.probe.Http + url: http://localhost:80 + postStart: + _type: service.container.probe.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)) + } + } +} diff --git a/modules/workload/service/src/secret/rand.go b/modules/workload/service/src/secret/rand.go new file mode 100644 index 0000000..24c48c1 --- /dev/null +++ b/modules/workload/service/src/secret/rand.go @@ -0,0 +1,52 @@ +package secret + +import ( + "math/rand" + "sync" + "time" + "unsafe" +) + +// Since rand.NewSource() doesn't provide safety under concurrent use, +// we need to use sync.Mutex here. +var rng = struct { + sync.Mutex + rand *rand.Rand +}{ + rand: rand.New(rand.NewSource(time.Now().UnixNano())), +} + +const ( + // We omit vowels from the set of available characters to reduce the chances + // of "bad words" being formed. + alphanums = "bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ2456789" + // No. of bits required to index into alphanums string. + alphanumsIdxBits = 5 + // Mask used to extract last alphanumsIdxBits of an int. + alphanumsIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = rng.rand.Int63(), maxAlphanumsPerInt + } + if idx := int(cache & alphanumsIdxMask); idx < len(alphanums) { + b[i] = alphanums[idx] + i-- + } + cache >>= alphanumsIdxBits + remain-- + } + + return *(*string)(unsafe.Pointer(&b)) +} diff --git a/modules/workload/service/src/secret/rand_test.go b/modules/workload/service/src/secret/rand_test.go new file mode 100644 index 0000000..4120b04 --- /dev/null +++ b/modules/workload/service/src/secret/rand_test.go @@ -0,0 +1,33 @@ +package secret + +import ( + "strings" + "testing" +) + +func TestGenerateRandomString(t *testing.T) { + valid := "bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ2456789" + for _, l := range []int{0, 1, 2, 10, 52} { + s := GenerateRandomString(l) + if len(s) != l { + t.Errorf("expected random string of size %d, actually got %q", l, s) + } + for _, c := range s { + if !strings.ContainsRune(valid, c) { + t.Errorf("expected valid characters, got %v", c) + } + } + } +} + +func BenchmarkRandomStringGeneration(b *testing.B) { + b.ResetTimer() + var s string + for i := 0; i < b.N; i++ { + s = GenerateRandomString(32) + } + b.StopTimer() + if len(s) == 0 { + b.Fatal(s) + } +} diff --git a/modules/workload/service/src/secret/secret.go b/modules/workload/service/src/secret/secret.go new file mode 100644 index 0000000..699b13c --- /dev/null +++ b/modules/workload/service/src/secret/secret.go @@ -0,0 +1,196 @@ +package secret + +import ( + "errors" + "fmt" + + "golang.org/x/exp/maps" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/modules" +) + +type secretGenerator struct { + project string + namespace string + secrets map[string]v1.Secret + secretStore *v1.SecretStore +} + +type GeneratorRequest struct { + // Project represents the Project name + Project string + // Namespace represents the K8s Namespace + Namespace string + // Workload represents the Workload configuration + Workload *v1.Workload + // SecretStore contains configuration to describe target secret store. + SecretStore *v1.SecretStore +} + +func NewSecretGenerator(request *GeneratorRequest) (modules.Generator, error) { + if len(request.Project) == 0 { + return nil, fmt.Errorf("project name must not be empty") + } + + var secretMap map[string]v1.Secret + if request.Workload.Service != nil { + secretMap = request.Workload.Service.Secrets + } else { + secretMap = request.Workload.Job.Secrets + } + + return &secretGenerator{ + project: request.Project, + secrets: secretMap, + namespace: request.Namespace, + secretStore: request.SecretStore, + }, nil +} + +func NewSecretGeneratorFunc(request *GeneratorRequest) modules.NewGeneratorFunc { + return func() (modules.Generator, error) { + return NewSecretGenerator(request) + } +} + +func (g *secretGenerator) Generate(spec *v1.Spec) error { + if spec.Resources == nil { + spec.Resources = make(v1.Resources, 0) + } + + for secretName, secretRef := range g.secrets { + secret, err := g.generateSecret(secretName, secretRef) + if err != nil { + return err + } + + resourceID := modules.KubernetesResourceID(secret.TypeMeta, secret.ObjectMeta) + err = modules.AppendToSpec( + v1.Kubernetes, + resourceID, + spec, + secret, + ) + if err != nil { + return err + } + } + + return nil +} + +// generateSecret generates target secret based on secret type. Most of these secret types are just semantic wrapper +// of native Kubernetes secret types:https://kubernetes.io/docs/concepts/configuration/secret/#secret-types, and more +// detailed usage info can be found in public documentation. +func (g *secretGenerator) generateSecret(secretName string, secretRef v1.Secret) (*corev1.Secret, error) { + switch secretRef.Type { + case "basic": + return g.generateBasic(secretName, secretRef) + case "token": + return g.generateToken(secretName, secretRef) + case "opaque": + return g.generateOpaque(secretName, secretRef) + case "certificate": + return g.generateCertificate(secretName, secretRef) + case "external": + return g.generateSecretWithExternalProvider(secretName, secretRef) + default: + return nil, fmt.Errorf("unrecognized secret type %s", secretRef.Type) + } +} + +// generateBasic generates secret used for basic authentication. The basic secret type +// is used for username / password pairs. +func (g *secretGenerator) generateBasic(secretName string, secretRef v1.Secret) (*corev1.Secret, error) { + secret := initBasicSecret(g.namespace, secretName, corev1.SecretTypeBasicAuth, secretRef.Immutable) + secret.Data = grabData(secretRef.Data, corev1.BasicAuthUsernameKey, corev1.BasicAuthPasswordKey) + + for _, key := range []string{corev1.BasicAuthUsernameKey, corev1.BasicAuthPasswordKey} { + if len(secret.Data[key]) == 0 { + v := GenerateRandomString(54) + secret.Data[key] = []byte(v) + } + } + + return secret, nil +} + +// generateToken generates secret used for password. Token secrets are useful for generating +// a password or secure string used for passwords when the user is already known or not required. +func (g *secretGenerator) generateToken(secretName string, secretRef v1.Secret) (*corev1.Secret, error) { + secret := initBasicSecret(g.namespace, secretName, corev1.SecretTypeOpaque, secretRef.Immutable) + secret.Data = grabData(secretRef.Data, "token") + + if len(secret.Data["token"]) == 0 { + v := GenerateRandomString(54) + secret.Data["token"] = []byte(v) + } + + return secret, nil +} + +// generateOpaque generates secret used for arbitrary user-defined data. +func (g *secretGenerator) generateOpaque(secretName string, secretRef v1.Secret) (*corev1.Secret, error) { + secret := initBasicSecret(g.namespace, secretName, corev1.SecretTypeOpaque, secretRef.Immutable) + secret.Data = grabData(secretRef.Data, maps.Keys(secretRef.Data)...) + return secret, nil +} + +// generateCertificate generates secret used for storing a certificate and its associated key. +// One common use for TLS Secrets is to configure encryption in transit for an Ingress, but +// you can also use it with other resources or directly in your v1. +func (g *secretGenerator) generateCertificate(secretName string, secretRef v1.Secret) (*corev1.Secret, error) { + secret := initBasicSecret(g.namespace, secretName, corev1.SecretTypeTLS, secretRef.Immutable) + secret.Data = grabData(secretRef.Data, corev1.TLSCertKey, corev1.TLSPrivateKeyKey) + return secret, nil +} + +// generateSecretWithExternalProvider retrieves target sensitive information from external secret provider and +// generates corresponding Kubernetes Secret object. +func (g *secretGenerator) generateSecretWithExternalProvider(secretName string, secretRef v1.Secret) (*corev1.Secret, error) { + if g.secretStore == nil { + return nil, errors.New("secret store is missing, please add valid secret store spec in workspace") + } + + secret := initBasicSecret(g.namespace, secretName, corev1.SecretTypeOpaque, secretRef.Immutable) + secret.Data = make(map[string][]byte) + + for key, ref := range secretRef.Data { + secret.Data[key] = []byte(ref) + } + + return secret, nil +} + +// grabData extracts keys mapping data from original string map. +func grabData(from map[string]string, keys ...string) map[string][]byte { + to := map[string][]byte{} + for _, key := range keys { + if v, ok := from[key]; ok { + // don't override a non-zero length value with zero length + if len(v) > 0 || len(to[key]) == 0 { + to[key] = []byte(v) + } + } + } + return to +} + +func initBasicSecret(namespace, name string, secretType corev1.SecretType, immutable bool) *corev1.Secret { + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Immutable: &immutable, + Type: secretType, + } + return secret +} diff --git a/modules/workload/service/src/secret/secret_test.go b/modules/workload/service/src/secret/secret_test.go new file mode 100644 index 0000000..c1db2e7 --- /dev/null +++ b/modules/workload/service/src/secret/secret_test.go @@ -0,0 +1,174 @@ +package secret + +import ( + "testing" + + "github.com/stretchr/testify/require" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + // ensure we can get correct secret store provider + _ "kusionstack.io/kusion/pkg/secrets/providers/register" +) + +var testProject = "helloworld" + +func initGeneratorRequest( + project string, + secrets map[string]v1.Secret, + secretStoreSpec *v1.SecretStore, +) *GeneratorRequest { + return &GeneratorRequest{ + Project: project, + Workload: &v1.Workload{ + Service: &v1.Service{ + Base: v1.Base{ + Secrets: secrets, + }, + }, + }, + Namespace: project, + SecretStore: secretStoreSpec, + } +} + +func initSecretStoreSpec(data []v1.FakeProviderData) *v1.SecretStore { + return &v1.SecretStore{ + Provider: &v1.ProviderSpec{ + Fake: &v1.FakeProvider{ + Data: data, + }, + }, + } +} + +func TestGenerateSecret(t *testing.T) { + tests := map[string]struct { + secretName string + secretType string + secretData map[string]string + + expectErr string + }{ + "create_basic_auth_secret": { + secretName: "secret-basic-auth", + secretType: "basic", + secretData: map[string]string{ + "username": "admin", + "password": "t0p-Secret", + }, + }, + "create_basic_auth_secret_empty_input": { + secretName: "secret-basic-auth", + secretType: "basic", + secretData: map[string]string{}, + }, + "create_token_secret": { + secretName: "secret-token", + secretType: "token", + secretData: map[string]string{ + "token": "YmFyCg==", + }, + }, + "create_token_secret_empty_input": { + secretName: "secret-token", + secretType: "token", + secretData: map[string]string{}, + }, + "create_opaque_secret": { + secretName: "empty-secret", + secretType: "opaque", + secretData: map[string]string{}, + }, + "create_opaque_secret_any_info": { + secretName: "empty-secret", + secretType: "opaque", + secretData: map[string]string{ + "accessKey": "dHJ1ZQ==", + }, + }, + "create_certificate_secret": { + secretName: "secret-tls", + secretType: "certificate", + secretData: map[string]string{ + "tls.crt": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNVakNDQWJz", + "tls.key": "RXhhbXBsZSBkYXRhIGZvciB0aGUgVExTIGNydCBmaWVsZA==", + }, + }, + "create_invalid_secret_invalid_type": { + secretName: "invalid-tls", + secretType: "cred", + expectErr: "unrecognized secret type cred", + }, + } + + // run all the tests + for name, test := range tests { + t.Run(name, func(t *testing.T) { + secrets := map[string]v1.Secret{ + name: { + Type: test.secretType, + Data: test.secretData, + }, + } + context := initGeneratorRequest(testProject, secrets, nil) + generator, _ := NewSecretGenerator(context) + err := generator.Generate(&v1.Spec{}) + if test.expectErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.EqualError(t, err, test.expectErr) + } + }) + } +} + +func TestGenerateSecretWithExternalRef(t *testing.T) { + tests := map[string]struct { + secretName string + secretType string + secretData map[string]string + + providerData []v1.FakeProviderData + + expectErr string + }{ + "create_external_secret": { + secretName: "api-auth", + secretType: "external", + secretData: map[string]string{ + "accessKey": "ref://api-auth-info/accessKey?version=1", + "secretKey": "ref://api-auth-info/secretKey?version=1", + }, + providerData: []v1.FakeProviderData{ + { + Key: "api-auth-info", + Value: `{"accessKey":"some sensitive info","secretKey":"*******"}`, + Version: "1", + }, + }, + }, + } + + // run all the tests + for name, test := range tests { + t.Run(name, func(t *testing.T) { + secrets := map[string]v1.Secret{ + name: { + Type: test.secretType, + Data: test.secretData, + }, + } + secretStoreSpec := initSecretStoreSpec(test.providerData) + context := initGeneratorRequest(testProject, secrets, secretStoreSpec) + generator, _ := NewSecretGenerator(context) + err := generator.Generate(&v1.Spec{}) + if test.expectErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.EqualError(t, err, test.expectErr) + } + }) + } +} diff --git a/modules/workload/service/src/service.go b/modules/workload/service/src/service.go new file mode 100644 index 0000000..3d67d60 --- /dev/null +++ b/modules/workload/service/src/service.go @@ -0,0 +1,229 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "gopkg.in/yaml.v2" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "kusionstack.io/kube-api/apps/v1alpha1" + "kusionstack.io/kusion-module-framework/pkg/module" + "kusionstack.io/kusion-module-framework/pkg/server" + kusionv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/modules" + "kusionstack.io/kusion/pkg/workspace" +) + +var ( + ErrEmptySelectors = errors.New("selectors must not be empty") + ErrInvalidPort = errors.New("port must be between 1 and 65535") + ErrInvalidTargetPort = errors.New("targetPort must be between 1 and 65535 if exist") + ErrInvalidProtocol = errors.New("protocol must be TCP or UDP") + ErrDuplicatePortProtocol = errors.New("port-protocol pair must not be duplicate") +) + +func (svc *Service) Generate(_ context.Context, request *module.GeneratorRequest) (*module.GeneratorResponse, error) { + defer func() { + if r := recover(); r != nil { + log.Debugf("failed to generate Service module: %v", r) + } + }() + + if request.DevConfig == nil { + log.Info("Service does not exist in AppConfig config") + return nil, nil + } + out, err := yaml.Marshal(request.DevConfig) + if err != nil { + return nil, err + } + + if err = yaml.Unmarshal(out, svc); err != nil { + return nil, fmt.Errorf("complete Service by dev config failed, %w", err) + } + + if err = completeServiceInput(svc, request.PlatformConfig); err != nil { + return nil, fmt.Errorf("complete Service by platform config failed, %w", err) + } + + uniqueAppName := modules.UniqueAppName(request.Project, request.Stack, request.App) + + // Create a slice of containers based on the App's containers along with related volumes and configMaps. + containers, volumes, configMaps, err := toOrderedContainers(svc.Containers, uniqueAppName) + if err != nil { + return nil, err + } + + res := make([]kusionv1.Resource, 0) + // Create ConfigMap objects based on the App's configuration. + for _, cm := range configMaps { + cm.Namespace = request.Project + resourceID := module.KubernetesResourceID(cm.TypeMeta, cm.ObjectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, &cm) + if err != nil { + return nil, err + } + res = append(res, *resource) + } + + labels := modules.MergeMaps(modules.UniqueAppLabels(request.Project, request.App), svc.Labels) + annotations := modules.MergeMaps(svc.Annotations) + selectors := modules.UniqueAppLabels(request.Project, request.App) + + // Create a K8s Workload object based on the App's configuration. + // common parts + objectMeta := metav1.ObjectMeta{ + Labels: labels, + Annotations: annotations, + Name: uniqueAppName, + Namespace: request.Project, + } + podTemplateSpec := corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + Annotations: annotations, + }, + Spec: corev1.PodSpec{ + Containers: containers, + Volumes: volumes, + }, + } + + var k8sResource runtime.Object + typeMeta := metav1.TypeMeta{} + + switch svc.Type { + case Deployment: + typeMeta = metav1.TypeMeta{ + APIVersion: appsv1.SchemeGroupVersion.String(), + Kind: string(Deployment), + } + spec := appsv1.DeploymentSpec{ + Replicas: svc.Replicas, + Selector: &metav1.LabelSelector{MatchLabels: selectors}, + Template: podTemplateSpec, + } + k8sResource = &appsv1.Deployment{ + TypeMeta: typeMeta, + ObjectMeta: objectMeta, + Spec: spec, + } + case Collaset: + typeMeta = metav1.TypeMeta{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: string(Collaset), + } + k8sResource = &v1alpha1.CollaSet{ + TypeMeta: typeMeta, + ObjectMeta: objectMeta, + Spec: v1alpha1.CollaSetSpec{ + Replicas: svc.Replicas, + Selector: &metav1.LabelSelector{MatchLabels: selectors}, + Template: podTemplateSpec, + }, + } + } + + // append the Deployment/Collaset resource to res. + resourceID := module.KubernetesResourceID(typeMeta, objectMeta) + resource, err := module.WrapK8sResourceToKusionResource(resourceID, k8sResource) + if err != nil { + return nil, err + } + res = append(res, *resource) + + // validate and complete service ports + if len(svc.Ports) != 0 { + if err = validate(selectors, svc.Ports); err != nil { + return nil, err + } + if err = complete(svc.Ports); err != nil { + return nil, err + } + } + response := module.GeneratorResponse{ + Resources: res, + } + log.Debugf("response: %v", response) + return &response, nil +} + +func validatePorts(ports []Port) error { + portProtocolRecord := make(map[string]struct{}) + for _, port := range ports { + if err := validatePort(&port); err != nil { + return fmt.Errorf("invalid port config %+v, %w", port, err) + } + + // duplicate "port-protocol" pairs are not allowed. + portProtocol := fmt.Sprintf("%d-%s", port.Port, port.Protocol) + if _, ok := portProtocolRecord[portProtocol]; ok { + return fmt.Errorf("invalid port config %+v, %v", port, ErrDuplicatePortProtocol) + } + portProtocolRecord[portProtocol] = struct{}{} + } + return nil +} + +func validatePort(port *Port) error { + if port.Port < 1 || port.Port > 65535 { + return ErrInvalidPort + } + if port.TargetPort < 0 || port.Port > 65535 { + return ErrInvalidTargetPort + } + if port.Protocol != TCP && port.Protocol != UDP { + return ErrInvalidProtocol + } + return nil +} + +func validate(selectors map[string]string, ports []Port) error { + if len(selectors) == 0 { + return ErrEmptySelectors + } + if err := validatePorts(ports); err != nil { + return err + } + return nil +} + +func complete(ports []Port) error { + for i := range ports { + if ports[i].TargetPort == 0 { + ports[i].TargetPort = ports[i].Port + } + } + return nil +} + +func completeServiceInput(service *Service, config kusionv1.GenericConfig) error { + if err := completeBaseWorkload(&service.Base, config); err != nil { + return err + } + serviceTypeStr, err := workspace.GetStringFromGenericConfig(config, ModuleServiceType) + platformServiceType := ServiceType(serviceTypeStr) + if err != nil { + return err + } + // if not set in workspace, use Deployment as default type + if platformServiceType == "" { + platformServiceType = Deployment + } + if platformServiceType != Deployment && platformServiceType != Collaset { + return fmt.Errorf("unsupported Service type %s", platformServiceType) + } + if service.Type == "" { + service.Type = platformServiceType + } + return nil +} + +func main() { + server.Start(&Service{}) +} diff --git a/modules/workload/service/src/service_test.go b/modules/workload/service/src/service_test.go new file mode 100644 index 0000000..669bd40 --- /dev/null +++ b/modules/workload/service/src/service_test.go @@ -0,0 +1,550 @@ +package main + +import ( + "context" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + yamlv2 "gopkg.in/yaml.v2" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "kusionstack.io/kube-api/apps/v1alpha1" + "kusionstack.io/kusion-module-framework/pkg/module" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" +) + +func Test_workloadServiceGenerator_Generate(t *testing.T) { + cm := `apiVersion: v1 +data: + example.txt: some file contents +kind: ConfigMap +metadata: + creationTimestamp: null + name: default-dev-foo-nginx-0 + namespace: default +` + var cmResource v1.Resource + k8sCm := &corev1.ConfigMap{} + _ = yaml.Unmarshal([]byte(cm), k8sCm) + unstructured, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(k8sCm) + cmResource.Attributes = unstructured + + csSvc := `apiVersion: v1 +kind: Service +metadata: + annotations: + service-workload-type: CollaSet + service.beta.kubernetes.io/alibaba-cloud-loadbalancer-spec: slb.s1.small + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + kusionstack.io/control: "true" + service-workload-type: CollaSet + name: default-dev-foo-public + namespace: default +spec: + ports: + - name: default-dev-foo-public-80-tcp + port: 80 + protocol: TCP + targetPort: 80 + selector: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + type: LoadBalancer +status: + loadBalancer: {} +` + var csSvcResource v1.Resource + k8sSvc := &corev1.Service{} + _ = yaml.Unmarshal([]byte(csSvc), k8sSvc) + unSvc, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(k8sSvc) + csSvcResource.Attributes = unSvc + + deploySvc := `apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/alibaba-cloud-loadbalancer-spec: slb.s1.small + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + kusionstack.io/control: "true" + service-workload-type: Deployment + name: default-dev-foo-public + namespace: default +spec: + ports: + - name: default-dev-foo-public-80-tcp + port: 80 + protocol: TCP + targetPort: 80 + selector: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + type: LoadBalancer +status: + loadBalancer: {} +` + var deploySvcRes v1.Resource + deployK8sSvc := &corev1.Service{} + _ = yaml.Unmarshal([]byte(deploySvc), deployK8sSvc) + unDeploySvc, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(deployK8sSvc) + deploySvcRes.Attributes = unDeploySvc + + cs := `apiVersion: apps.kusionstack.io/v1alpha1 +kind: CollaSet +metadata: + annotations: + service-workload-type: CollaSet + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + service-workload-type: CollaSet + name: default-dev-foo + namespace: default +spec: + replicas: 2 + scaleStrategy: {} + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + template: + metadata: + annotations: + service-workload-type: CollaSet + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + service-workload-type: CollaSet + spec: + containers: + - image: nginx:v1 + name: nginx + resources: {} + volumeMounts: + - mountPath: /tmp + name: default-dev-foo-nginx-0 + volumes: + - configMap: + defaultMode: 511 + name: default-dev-foo-nginx-0 + name: default-dev-foo-nginx-0 + updateStrategy: {} +status: {} +` + var csResource v1.Resource + k8sCS := &v1alpha1.CollaSet{} + _ = yaml.Unmarshal([]byte(cs), k8sCS) + unCS, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(k8sCS) + csResource.Attributes = unCS + + deploy := `apiVersion: apps/v1 +kind: Deployment +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + service-workload-type: Deployment + name: default-dev-foo + namespace: default +spec: + replicas: 4 + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + service-workload-type: Deployment + spec: + containers: + - image: nginx:v1 + name: nginx + resources: {} + volumeMounts: + - mountPath: /tmp + name: default-dev-foo-nginx-0 + volumes: + - configMap: + defaultMode: 511 + name: default-dev-foo-nginx-0 + name: default-dev-foo-nginx-0 +status: {} +` + var deployRes v1.Resource + k8sDep := &appsv1.Deployment{} + _ = yaml.Unmarshal([]byte(deploy), k8sDep) + unDep, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(k8sDep) + deployRes.Attributes = unDep + + deployWithProbe := `apiVersion: apps/v1 +kind: Deployment +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + service-workload-type: Deployment + name: default-dev-foo + namespace: default +spec: + replicas: 4 + selector: + matchLabels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + app.kubernetes.io/name: foo + app.kubernetes.io/part-of: default + service-workload-type: Deployment + spec: + containers: + - image: nginx:v1 + lifecycle: + postStart: + exec: + command: + - /bin/true + name: nginx + readinessProbe: + tcpSocket: + host: localhost + port: 8888 + resources: {} + volumeMounts: + - mountPath: /tmp + name: default-dev-foo-nginx-0 + volumes: + - configMap: + defaultMode: 511 + name: default-dev-foo-nginx-0 + name: default-dev-foo-nginx-0 +status: {} +` + var deployWithProbeRes v1.Resource + k8sDepWithProbe := &appsv1.Deployment{} + _ = yaml.Unmarshal([]byte(deployWithProbe), k8sDepWithProbe) + unDepWithProbe, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(k8sDepWithProbe) + deployWithProbeRes.Attributes = unDepWithProbe + r2 := new(int32) + *r2 = 2 + + svcConfig := &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + Files: map[string]FileSpec{ + "/tmp/example.txt": { + Content: "some file contents", + Mode: "0777", + }, + }, + }, + }, + Replicas: r2, + }, + Ports: []Port{ + { + Port: 80, + Protocol: "TCP", + }, + }, + } + + var devConfig map[string]interface{} + temp, _ := yamlv2.Marshal(svcConfig) + _ = yamlv2.Unmarshal(temp, &devConfig) + + serviceWithProbe := &v1.Service{ + Base: v1.Base{ + Containers: map[string]v1.Container{ + "nginx": { + Image: "nginx:v1", + Files: map[string]v1.FileSpec{ + "/tmp/example.txt": { + Content: "some file contents", + Mode: "0777", + }, + }, + ReadinessProbe: &v1.Probe{ProbeHandler: &v1.ProbeHandler{ + TypeWrapper: v1.TypeWrapper{Type: v1.TypeTCP}, + ExecAction: nil, + HTTPGetAction: nil, + TCPSocketAction: &v1.TCPSocketAction{URL: "localhost:8888"}, + }}, + Lifecycle: &v1.Lifecycle{ + PostStart: &v1.LifecycleHandler{ + TypeWrapper: v1.TypeWrapper{Type: v1.TypeExec}, + ExecAction: &v1.ExecAction{Command: []string{ + "/bin/true", + }}, + HTTPGetAction: nil, + }, + }, + }, + }, + }, + Ports: []v1.Port{ + { + Port: 80, + Protocol: "TCP", + }, + }, + } + var devConfigWithProbe map[string]interface{} + temp, _ = yamlv2.Marshal(serviceWithProbe) + _ = yamlv2.Unmarshal(temp, &devConfigWithProbe) + + tests := []struct { + name string + request *module.GeneratorRequest + want *module.GeneratorResponse + wantErr bool + }{ + { + name: "CollaSet", + request: &module.GeneratorRequest{ + Project: "default", + Stack: "dev", + App: "foo", + DevConfig: devConfig, + PlatformConfig: v1.GenericConfig{ + "type": "CollaSet", + "labels": v1.GenericConfig{ + "service-workload-type": "CollaSet", + }, + "annotations": v1.GenericConfig{ + "service-workload-type": "CollaSet", + }, + }, + }, + wantErr: false, + want: &module.GeneratorResponse{ + Resources: []v1.Resource{cmResource, csResource}, + }, + }, + { + name: "Deployment", + request: &module.GeneratorRequest{ + Project: "default", + Stack: "dev", + App: "foo", + DevConfig: devConfig, + PlatformConfig: v1.GenericConfig{ + "replicas": 4, + "labels": v1.GenericConfig{ + "service-workload-type": "Deployment", + }, + }, + }, + wantErr: false, + want: &module.GeneratorResponse{ + Resources: []v1.Resource{cmResource, deployRes, deploySvcRes}, + }, + }, + { + name: "DeploymentWithProbe", + request: &module.GeneratorRequest{ + Project: "default", + Stack: "dev", + App: "foo", + DevConfig: devConfig, + PlatformConfig: v1.GenericConfig{ + "replicas": 4, + "labels": v1.GenericConfig{ + "service-workload-type": "Deployment", + }, + }, + }, + wantErr: false, + want: &module.GeneratorResponse{ + Resources: []v1.Resource{cmResource, deployWithProbeRes, deploySvcRes}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &Service{} + got, err := svc.Generate(context.Background(), tt.request) + if (err != nil) != tt.wantErr { + t.Errorf("Service.Generate() error = %v, wantErr %v", err, tt.wantErr) + return + } + for i, resource := range got.Resources { + // todo the order of attributes is not the same, only compare the length here + if !reflect.DeepEqual(len(resource.Attributes), len(tt.want.Resources[i].Attributes)) { + t.Errorf("Service.Generate() = %v, want %v", resource.Attributes, tt.want.Resources[i].Attributes) + } + } + }) + } +} + +func TestCompleteServiceInput(t *testing.T) { + r2 := int32(2) + + testcases := []struct { + name string + service *Service + config v1.GenericConfig + success bool + completedService *Service + }{ + { + name: "use type in workspace config", + service: &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + }, + }, + Replicas: &r2, + Labels: map[string]string{ + "k1": "v1", + }, + Annotations: map[string]string{ + "k1": "v1", + }, + }, + }, + config: v1.GenericConfig{ + "type": "CollaSet", + }, + success: true, + completedService: &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + }, + }, + Replicas: &r2, + Labels: map[string]string{ + "k1": "v1", + }, + Annotations: map[string]string{ + "k1": "v1", + }, + }, + Type: "CollaSet", + }, + }, + { + name: "use default type", + service: &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + }, + }, + Replicas: &r2, + Labels: map[string]string{ + "k1": "v1", + }, + Annotations: map[string]string{ + "k1": "v1", + }, + }, + }, + config: nil, + success: true, + completedService: &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + }, + }, + Replicas: &r2, + Labels: map[string]string{ + "k1": "v1", + }, + Annotations: map[string]string{ + "k1": "v1", + }, + }, + Type: "Deployment", + }, + }, + { + name: "invalid field type", + service: &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + }, + }, + Replicas: &r2, + Labels: map[string]string{ + "k1": "v1", + }, + Annotations: map[string]string{ + "k1": "v1", + }, + }, + }, + config: v1.GenericConfig{ + "type": 1, + }, + success: false, + completedService: nil, + }, + { + name: "unsupported type", + service: &Service{ + Base: Base{ + Containers: map[string]Container{ + "nginx": { + Image: "nginx:v1", + }, + }, + Replicas: &r2, + Labels: map[string]string{ + "k1": "v1", + }, + Annotations: map[string]string{ + "k1": "v1", + }, + }, + }, + config: v1.GenericConfig{ + "type": "unsupported", + }, + success: false, + completedService: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := completeServiceInput(tc.service, tc.config) + assert.Equal(t, tc.success, err == nil) + if tc.success { + assert.True(t, reflect.DeepEqual(tc.service, tc.completedService)) + } + }) + } +} diff --git a/modules/workload/service/src/type.go b/modules/workload/service/src/type.go new file mode 100644 index 0000000..339cd18 --- /dev/null +++ b/modules/workload/service/src/type.go @@ -0,0 +1,202 @@ +package main + +import "gopkg.in/yaml.v2" + +const ( + BuiltinModulePrefix = "" + ProbePrefix = "service.container.probe." + TypeHTTP = BuiltinModulePrefix + ProbePrefix + "Http" + TypeExec = BuiltinModulePrefix + ProbePrefix + "Exec" + TypeTCP = BuiltinModulePrefix + ProbePrefix + "Tcp" +) + +// 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"` +} + +const ( + FieldLabels = "labels" + FieldAnnotations = "annotations" + FieldReplicas = "replicas" +) + +// 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 ( + ModuleService = "service" + ModuleServiceType = "type" + 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"` +} diff --git a/modules/workload/service/src/unmarshal.go b/modules/workload/service/src/unmarshal.go new file mode 100644 index 0000000..56f15d8 --- /dev/null +++ b/modules/workload/service/src/unmarshal.go @@ -0,0 +1,114 @@ +package main + +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 TypeHTTP: + handler := &HTTPGetAction{} + err = json.Unmarshal(data, handler) + p.HTTPGetAction = handler + case TypeExec: + handler := &ExecAction{} + err = json.Unmarshal(data, handler) + p.ExecAction = handler + case TypeTCP: + 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 TypeHTTP: + handler := &HTTPGetAction{} + err = unmarshal(handler) + p.HTTPGetAction = handler + case TypeExec: + handler := &ExecAction{} + err = unmarshal(handler) + p.ExecAction = handler + case TypeTCP: + 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 TypeHTTP: + handler := &HTTPGetAction{} + err = json.Unmarshal(data, handler) + l.HTTPGetAction = handler + case TypeExec: + 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 TypeHTTP: + handler := &HTTPGetAction{} + err = unmarshal(handler) + l.HTTPGetAction = handler + case TypeExec: + handler := &ExecAction{} + err = unmarshal(handler) + l.ExecAction = handler + default: + return errors.New("unrecognized lifecycle handler type") + } + + return err +} diff --git a/modules/workload/service/src/unmarshal_test.go b/modules/workload/service/src/unmarshal_test.go new file mode 100644 index 0000000..f4b833c --- /dev/null +++ b/modules/workload/service/src/unmarshal_test.go @@ -0,0 +1,400 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + "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":"service.container.probe.Http","url":"http://localhost:80"},"initialDelaySeconds":10}}`, + result: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"service.container.probe.Exec","command":["cat","/tmp/healthy"]},"initialDelaySeconds":10}}`, + result: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"cat", "/tmp/healthy"}, + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `{"image":"nginx:v1","readinessProbe":{"probeHandler":{"_type":"service.container.probe.Tcp","url":"127.0.0.1:8080"},"initialDelaySeconds":10}}`, + result: Container{ + Image: "nginx:v1", + ReadinessProbe: &Probe{ + ProbeHandler: &ProbeHandler{ + TypeWrapper: TypeWrapper{Type: "service.container.probe.Tcp"}, + TCPSocketAction: &TCPSocketAction{ + URL: "127.0.0.1:8080", + }, + }, + InitialDelaySeconds: 10, + }, + }, + }, + { + input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"service.container.probe.Exec","command":["/bin/sh","-c","echo Hello from the postStart handler \u003e /usr/share/message"]},"postStart":{"_type":"service.container.probe.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{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"}, + }, + }, + }, + }, + }, + { + input: `{"image":"nginx:v1","lifecycle":{"preStop":{"_type":"service.container.probe.Http","url":"http://localhost:80"},"postStart":{"_type":"service.container.probe.Http","url":"http://localhost:80"}}}`, + result: Container{ + Image: "nginx:v1", + Lifecycle: &Lifecycle{ + PostStart: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.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: service.container.probe.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: "service.container.probe.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: service.container.probe.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: "service.container.probe.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: service.container.probe.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: "service.container.probe.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: service.container.probe.Exec + command: + - /bin/sh + - -c + - echo Hello from the postStart handler > /usr/share/message + postStart: + _type: service.container.probe.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{"service.container.probe.Exec"}, + ExecAction: &ExecAction{ + Command: []string{"/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"}, + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.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: service.container.probe.Http + url: http://localhost:80 + postStart: + _type: service.container.probe.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{"service.container.probe.Http"}, + HTTPGetAction: &HTTPGetAction{ + URL: "http://localhost:80", + }, + }, + PreStop: &LifecycleHandler{ + TypeWrapper: TypeWrapper{"service.container.probe.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/modules/workload/service/src/workload_base.go b/modules/workload/service/src/workload_base.go new file mode 100644 index 0000000..1867546 --- /dev/null +++ b/modules/workload/service/src/workload_base.go @@ -0,0 +1,451 @@ +package main + +import ( + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/imdario/mergo" + "golang.org/x/exp/maps" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + kusionv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/modules" + "kusionstack.io/kusion/pkg/util/net" + "kusionstack.io/kusion/pkg/workspace" +) + +func toOrderedContainers( + appContainers map[string]Container, + uniqueAppName string, +) ([]corev1.Container, []corev1.Volume, []corev1.ConfigMap, error) { + // Create a slice of containers based on the App's containers. + var containers []corev1.Container + + // Create a slice of volumes and configMaps based on the containers' files to be created. + var volumes []corev1.Volume + var configMaps []corev1.ConfigMap + + if err := modules.ForeachOrdered(appContainers, func(containerName string, c Container) error { + // Create a slice of env vars based on the container's env vars. + var envs []corev1.EnvVar + for _, m := range c.Env { + envs = append(envs, *MagicEnvVar(m.Key.(string), m.Value.(string))) + } + + resourceRequirements, err := handleResourceRequirementsV1(c.Resources) + if err != nil { + return err + } + + // Create a container object. + ctn := corev1.Container{ + Name: containerName, + Image: c.Image, + Command: c.Command, + Args: c.Args, + WorkingDir: c.WorkingDir, + Env: envs, + Resources: resourceRequirements, + } + if err = updateContainer(&c, &ctn); err != nil { + return err + } + + // Append the configMap, volume and volumeMount objects into the corresponding slices. + volumesContainer, volumeMounts, configMapsContainer, err := handleFileCreation(c, uniqueAppName, containerName) + if err != nil { + return err + } + volumes = append(volumes, volumesContainer...) + configMaps = append(configMaps, configMapsContainer...) + ctn.VolumeMounts = append(ctn.VolumeMounts, volumeMounts...) + + // Append more volumes and volumeMounts + otherVolumes, otherVolumeMounts, err := handleDirCreation(c) + if err != nil { + return err + } + volumes = append(volumes, otherVolumes...) + ctn.VolumeMounts = append(ctn.VolumeMounts, otherVolumeMounts...) + + // Append the container object to the containers slice. + containers = append(containers, ctn) + return nil + }); err != nil { + return nil, nil, nil, err + } + return containers, volumes, configMaps, nil +} + +// updateContainer updates corev1.Container with passed parameters. +func updateContainer(in *Container, out *corev1.Container) error { + if in.ReadinessProbe != nil { + readinessProbe, err := convertKusionProbeToV1Probe(in.ReadinessProbe) + if err != nil { + return err + } + out.ReadinessProbe = readinessProbe + } + + if in.LivenessProbe != nil { + livenessProbe, err := convertKusionProbeToV1Probe(in.LivenessProbe) + if err != nil { + return err + } + out.LivenessProbe = livenessProbe + } + + if in.StartupProbe != nil { + startupProbe, err := convertKusionProbeToV1Probe(in.StartupProbe) + if err != nil { + return err + } + out.StartupProbe = startupProbe + } + + if in.Lifecycle != nil { + lifecycle, err := convertKusionLifecycleToV1Lifecycle(in.Lifecycle) + if err != nil { + return err + } + out.Lifecycle = lifecycle + } + + return nil +} + +// handleResourceRequirementsV1 parses the resources parameter if specified and +// returns ResourceRequirements. +func handleResourceRequirementsV1(resources map[string]string) (corev1.ResourceRequirements, error) { + result := corev1.ResourceRequirements{} + if resources == nil { + return result, nil + } + for key, value := range resources { + resourceName := corev1.ResourceName(key) + requests, limits, err := populateResourceLists(resourceName, value) + if err != nil { + return result, err + } + if requests != nil && result.Requests == nil { + result.Requests = make(corev1.ResourceList) + } + maps.Copy(result.Requests, requests) + if limits != nil && result.Limits == nil { + result.Limits = make(corev1.ResourceList) + } + maps.Copy(result.Limits, limits) + } + return result, nil +} + +// populateResourceLists takes strings of form =[-] and +// returns request&limit ResourceList. +func populateResourceLists(name corev1.ResourceName, spec string) (corev1.ResourceList, corev1.ResourceList, error) { + requests := corev1.ResourceList{} + limits := corev1.ResourceList{} + + parts := strings.Split(spec, "-") + if len(parts) == 1 { + resourceQuantity, err := resource.ParseQuantity(parts[0]) + if err != nil { + return nil, nil, err + } + limits[name] = resourceQuantity + } else if len(parts) == 2 { + resourceQuantity, err := resource.ParseQuantity(parts[0]) + if err != nil { + return nil, nil, err + } + requests[name] = resourceQuantity + resourceQuantity, err = resource.ParseQuantity(parts[1]) + if err != nil { + return nil, nil, err + } + limits[name] = resourceQuantity + } + + return requests, limits, nil +} + +// convertKusionProbeToV1Probe converts Kusion Probe to Kubernetes Probe types. +func convertKusionProbeToV1Probe(p *Probe) (*corev1.Probe, error) { + result := &corev1.Probe{ + InitialDelaySeconds: p.InitialDelaySeconds, + TimeoutSeconds: p.TimeoutSeconds, + PeriodSeconds: p.PeriodSeconds, + SuccessThreshold: p.SuccessThreshold, + FailureThreshold: p.FailureThreshold, + } + probeHandler := p.ProbeHandler + switch probeHandler.Type { + case TypeHTTP: + action, err := httpGetAction(probeHandler.HTTPGetAction.URL, probeHandler.Headers) + if err != nil { + return nil, err + } + result.HTTPGet = action + case TypeExec: + result.Exec = &corev1.ExecAction{Command: probeHandler.Command} + case TypeTCP: + action, err := tcpSocketAction(probeHandler.TCPSocketAction.URL) + if err != nil { + return nil, err + } + result.TCPSocket = action + } + return result, nil +} + +// convertKusionLifecycleToV1Lifecycle converts Kusion Lifecycle to Kubernetes Lifecycle types. +func convertKusionLifecycleToV1Lifecycle(l *Lifecycle) (*corev1.Lifecycle, error) { + result := &corev1.Lifecycle{} + if l.PreStop != nil { + preStop, err := lifecycleHandler(l.PreStop) + if err != nil { + return nil, err + } + result.PreStop = preStop + } + if l.PostStart != nil { + postStart, err := lifecycleHandler(l.PostStart) + if err != nil { + return nil, err + } + result.PostStart = postStart + } + return result, nil +} + +func lifecycleHandler(in *LifecycleHandler) (*corev1.LifecycleHandler, error) { + result := &corev1.LifecycleHandler{} + switch in.Type { + case TypeHTTP: + action, err := httpGetAction(in.HTTPGetAction.URL, in.Headers) + if err != nil { + return nil, err + } + result.HTTPGet = action + case TypeExec: + result.Exec = &corev1.ExecAction{Command: in.Command} + } + return result, nil +} + +func httpGetAction(urlstr string, headers map[string]string) (*corev1.HTTPGetAction, error) { + u, err := url.Parse(urlstr) + if err != nil { + return nil, err + } + + httpHeaders := make([]corev1.HTTPHeader, 0, len(headers)) + for k, v := range headers { + httpHeaders = append(httpHeaders, corev1.HTTPHeader{ + Name: k, + Value: v, + }) + } + + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" { + host = "" + } + + return &corev1.HTTPGetAction{ + Path: u.Path, + Port: intstr.Parse(u.Port()), + Host: host, + Scheme: corev1.URIScheme(strings.ToUpper(u.Scheme)), + HTTPHeaders: httpHeaders, + }, nil +} + +func tcpSocketAction(urlstr string) (*corev1.TCPSocketAction, error) { + host, port, err := net.ParseHostPort(urlstr) + if err != nil { + return nil, err + } + + return &corev1.TCPSocketAction{ + Port: intstr.Parse(port), + Host: host, + }, nil +} + +// handleFileCreation handles the creation of the files declared in container.Files +// and returns the generated ConfigMap, Volume and VolumeMount. +func handleFileCreation(c Container, uniqueAppName, containerName string) ( + volumes []corev1.Volume, + volumeMounts []corev1.VolumeMount, + configMaps []corev1.ConfigMap, + err error, +) { + var idx int + err = modules.ForeachOrdered(c.Files, func(k string, v FileSpec) error { + // The declared file path needs to include the file name. + if filepath.Base(k) == "." || filepath.Base(k) == "/" { + return fmt.Errorf("the declared file path needs to include the file name") + } + + // Specify the name of the configMap and volume to be created. + configMapName := uniqueAppName + "-" + containerName + "-" + strconv.Itoa(idx) + idx++ + + // Change the mode attribute from string into int32. + var modeInt32 int32 + if modeInt64, err2 := strconv.ParseInt(v.Mode, 0, 64); err2 != nil { + return err2 + } else { + modeInt32 = int32(modeInt64) + } + + if v.ContentFrom != "" { + sec, ok, parseErr := parseSecretReference(v.ContentFrom) + if parseErr != nil || !ok { + return fmt.Errorf("invalid content from str") + } + + volumes = append(volumes, corev1.Volume{ + Name: sec.Name, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: sec.Name, + DefaultMode: &modeInt32, + }, + }, + }) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: sec.Name, + MountPath: filepath.Join("/", k), + SubPath: sec.Key, + }) + } else if v.Content != "" { + // Create the file content with configMap. + data := make(map[string]string) + data[filepath.Base(k)] = v.Content + + configMaps = append(configMaps, corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + }, + Data: data, + }) + + volumes = append(volumes, corev1.Volume{ + Name: configMapName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + DefaultMode: &modeInt32, + }, + }, + }) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configMapName, + MountPath: filepath.Dir(k), + }) + } + return nil + }) + return +} + +// handleDirCreation handles the creation of folder declared in container.Dirs and returns +// the generated Volume and VolumeMount. +func handleDirCreation(c Container) (volumes []corev1.Volume, volumeMounts []corev1.VolumeMount, err error) { + err = modules.ForeachOrdered(c.Dirs, func(mountPath string, v string) error { + sec, ok, parseErr := parseSecretReference(v) + if parseErr != nil || !ok { + return fmt.Errorf("invalid dir configuration") + } + + volumes = append(volumes, corev1.Volume{ + Name: sec.Name, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: sec.Name, + }, + }, + }) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: sec.Name, + MountPath: filepath.Join("/", mountPath), + }) + return nil + }) + return +} + +// completeBaseWorkload uses config from workspace to complete the Workload base config. +func completeBaseWorkload(base *Base, config kusionv1.GenericConfig) error { + replicas, err := workspace.GetInt32PointerFromGenericConfig(config, FieldReplicas) + if err != nil { + return err + } + + // override the base replicas with the value from workspace if it is null + if base.Replicas == nil { + base.Replicas = replicas + } + labels, err := workspace.GetStringMapFromGenericConfig(config, FieldLabels) + if err != nil { + return err + } + if labels != nil { + if err = mergo.Merge(&base.Labels, labels); err != nil { + return err + } + } + annotations, err := workspace.GetStringMapFromGenericConfig(config, FieldAnnotations) + if err != nil { + return err + } + if annotations != nil { + if err = mergo.Merge(&base.Annotations, annotations); err != nil { + return err + } + } + return nil +} + +type secretReference struct { + Name string + Key string +} + +// parseSecretReference takes secret reference string as parameter and returns secretReference obj. +// Parameter `ref` is expected in following format: secret://sec-name/key, if the provided ref str +// is not in valid format, this function will return false or err. +func parseSecretReference(ref string) (result secretReference, _ bool, _ error) { + if strings.HasPrefix(ref, "${secret://") && strings.HasSuffix(ref, "}") { + ref = ref[2 : len(ref)-1] + } + + if !strings.HasPrefix(ref, "secret://") { + return result, false, nil + } + + u, err := url.Parse(ref) + if err != nil { + return result, false, err + } + + result.Name = u.Host + result.Key, _, _ = strings.Cut(strings.TrimPrefix(u.Path, "/"), "/") + + return result, true, nil +}