diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5d4aea3a4..e4003f18b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,9 +1,11 @@ name: build on: + merge_group: pull_request: branches: - main + - stable jobs: build-autoscaler: diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index cd3a3dbb0..eec69175e 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1,9 +1,11 @@ name: E2E Tests on: + merge_group: pull_request: branches: - main + - stable concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} diff --git a/.github/workflows/go-mod-tidy.yml b/.github/workflows/go-mod-tidy.yml index ae75b75e7..d2734936f 100644 --- a/.github/workflows/go-mod-tidy.yml +++ b/.github/workflows/go-mod-tidy.yml @@ -1,5 +1,6 @@ name: go mod tidy on: + merge_group: pull_request: jobs: diff --git a/.gitignore b/.gitignore index c12b445e0..7fde39a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules .DS_Store go.work.sum cli/odigos -.venv \ No newline at end of file +.venv +**/__pycache__/ +**/*.pyc +serving-certs/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index e51bde1ec..aec4ea6f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,10 @@ "request": "launch", "mode": "debug", "program": "${workspaceFolder}/instrumentor", - "cwd": "${workspaceFolder}/instrumentor" + "cwd": "${workspaceFolder}/instrumentor", + "env": { + "LOCAL_MUTATING_WEBHOOK_CERT_DIR": "${workspaceFolder}/serving-certs" + } }, { "name": "frontend", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28224c7c9..c2327488a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,3 +167,36 @@ make debug-odiglet Then, you can attach a debugger to the Odiglet pod. For example, if you are using Goland, you can follow the instructions [here](https://www.jetbrains.com/help/go/attach-to-running-go-processes-with-debugger.html#step-3-create-the-remote-run-debug-configuration-on-the-client-computer) to attach to a remote process. For Visual Studio Code, you can use the `.vscode/launch.json` file in this repo to attach to the Odiglet pod. + + + +## Instrumentor + +### Debugging +If the Mutating Webhook is enabled, follow these steps: + +1. Copy the TLS certificate and key: +Create a local directory and extract the certificate and key by running the following command: +``` +mkdir -p serving-certs && kubectl get secret instrumentor-webhook-cert -n odigos-system -o jsonpath='{.data.tls\.crt}' | base64 -d > serving-certs/tls.crt && kubectl get secret instrumentor-webhook-cert -n odigos-system -o jsonpath='{.data.tls\.key}' | base64 -d > serving-certs/tls.key +``` + + +2. Apply this service to the cluster, it will replace the existing `odigos-instrumentor` service: + +``` +apiVersion: v1 +kind: Service +metadata: + name: odigos-instrumentor + namespace: odigos-system +spec: + type: ExternalName + externalName: host.docker.internal + ports: + - name: webhook-server + port: 9443 + protocol: TCP +``` + +Once this is done, you can use the .vscode/launch.json configuration and run instrumentor local for debugging. \ No newline at end of file diff --git a/agents/python/configurator/__init__.py b/agents/python/configurator/__init__.py index 30d9e83b1..501dc3ebd 100644 --- a/agents/python/configurator/__init__.py +++ b/agents/python/configurator/__init__.py @@ -5,11 +5,10 @@ MINIMUM_PYTHON_SUPPORTED_VERSION = (3, 8) class OdigosPythonConfigurator(sdk_config._BaseConfigurator): - def _configure(self, **kwargs): trace_exporters, metric_exporters, log_exporters = sdk_config._import_exporters( - sdk_config._get_exporter_names("traces"), - sdk_config._get_exporter_names("metrics"), - sdk_config._get_exporter_names("logs"), + ['otlp_proto_http'] if sdk_config._get_exporter_names("traces") else [], + [], + [], ) initialize_components(trace_exporters, metric_exporters, log_exporters) \ No newline at end of file diff --git a/cli/cmd/resources/autoscaler.go b/cli/cmd/resources/autoscaler.go index a0047fa40..69c1aa99c 100644 --- a/cli/cmd/resources/autoscaler.go +++ b/cli/cmd/resources/autoscaler.go @@ -467,7 +467,7 @@ func NewAutoscalerDeployment(ns string, version string, imagePrefix string, imag Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ "cpu": resource.MustParse("500m"), - "memory": *resource.NewQuantity(134217728, resource.BinarySI), + "memory": *resource.NewQuantity(536870912, resource.BinarySI), }, Requests: corev1.ResourceList{ "cpu": resource.MustParse("10m"), diff --git a/cli/cmd/resources/instrumentor.go b/cli/cmd/resources/instrumentor.go index 358a18c6a..238363e0b 100644 --- a/cli/cmd/resources/instrumentor.go +++ b/cli/cmd/resources/instrumentor.go @@ -501,7 +501,7 @@ func NewInstrumentorDeployment(ns string, version string, telemetryEnabled bool, Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ "cpu": resource.MustParse("500m"), - "memory": *resource.NewQuantity(134217728, resource.BinarySI), + "memory": *resource.NewQuantity(536870912, resource.BinarySI), }, Requests: corev1.ResourceList{ "cpu": resource.MustParse("10m"), diff --git a/cli/cmd/resources/scheduler.go b/cli/cmd/resources/scheduler.go index 6cdf42be5..2b8bbc35f 100644 --- a/cli/cmd/resources/scheduler.go +++ b/cli/cmd/resources/scheduler.go @@ -259,7 +259,7 @@ func NewSchedulerDeployment(ns string, version string, imagePrefix string) *apps Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ "cpu": resource.MustParse("500m"), - "memory": *resource.NewQuantity(134217728, resource.BinarySI), + "memory": *resource.NewQuantity(536870912, resource.BinarySI), }, Requests: corev1.ResourceList{ "cpu": resource.MustParse("10m"), diff --git a/cli/cmd/resources/ui.go b/cli/cmd/resources/ui.go index dbd8bc6e6..6cd464c23 100644 --- a/cli/cmd/resources/ui.go +++ b/cli/cmd/resources/ui.go @@ -94,7 +94,7 @@ func NewUIDeployment(ns string, version string, imagePrefix string) *appsv1.Depl Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ "cpu": resource.MustParse("500m"), - "memory": *resource.NewQuantity(134217728, resource.BinarySI), + "memory": *resource.NewQuantity(536870912, resource.BinarySI), }, Requests: corev1.ResourceList{ "cpu": resource.MustParse("10m"), diff --git a/docs/instrumentations/python/python.mdx b/docs/instrumentations/python/python.mdx index bb70d05c1..21b4eb6c3 100644 --- a/docs/instrumentations/python/python.mdx +++ b/docs/instrumentations/python/python.mdx @@ -41,6 +41,7 @@ The following Python modules will be auto instrumented by Odigos: ### HTTP Frameworks - [`asgi`](https://pypi.org/project/asgiref/) versions `asgiref >= 3.0.0, < 4.0.0` - [`django`](https://pypi.org/project/Django/) versions `django >= 1.10.0` +Please note that for Django instrumentation to work, you need to set the [DJANGO_SETTINGS_MODULE](https://docs.djangoproject.com/en/5.1/topics/settings/#envvar-DJANGO_SETTINGS_MODULE) environment variable. Make sure to set this in your Kubernetes manifest or Dockerfile to ensure proper configuration. - [`fastapi`](https://pypi.org/project/fastapi/) versions `fastapi >= 0.58.0, < 0.59.0`, `fastapi-slim >= 0.111.0, < 0.112.0` - [`flask`](https://pypi.org/project/Flask/) versions `flask >= 1.0.0` - [`pyramid`](https://pypi.org/project/pyramid/) versions `pyramid >= 1.7.0` diff --git a/helm/odigos/templates/autoscaler/deployment.yaml b/helm/odigos/templates/autoscaler/deployment.yaml index 93e424262..f7ccf419e 100644 --- a/helm/odigos/templates/autoscaler/deployment.yaml +++ b/helm/odigos/templates/autoscaler/deployment.yaml @@ -55,7 +55,7 @@ spec: resources: limits: cpu: 500m - memory: 128Mi + memory: 512Mi requests: cpu: 10m memory: 64Mi diff --git a/helm/odigos/templates/instrumentor/deployment.yaml b/helm/odigos/templates/instrumentor/deployment.yaml index 6093737de..43c106181 100644 --- a/helm/odigos/templates/instrumentor/deployment.yaml +++ b/helm/odigos/templates/instrumentor/deployment.yaml @@ -58,7 +58,7 @@ spec: resources: limits: cpu: 500m - memory: 128Mi + memory: 512Mi requests: cpu: 10m memory: 64Mi diff --git a/helm/odigos/templates/scheduler/deployment.yaml b/helm/odigos/templates/scheduler/deployment.yaml index b238f3876..081d8d6e6 100644 --- a/helm/odigos/templates/scheduler/deployment.yaml +++ b/helm/odigos/templates/scheduler/deployment.yaml @@ -50,7 +50,7 @@ spec: resources: limits: cpu: 500m - memory: 128Mi + memory: 512Mi requests: cpu: 10m memory: 64Mi diff --git a/helm/odigos/templates/ui/deployment.yaml b/helm/odigos/templates/ui/deployment.yaml index b071a7103..12a363e85 100644 --- a/helm/odigos/templates/ui/deployment.yaml +++ b/helm/odigos/templates/ui/deployment.yaml @@ -38,7 +38,7 @@ spec: resources: limits: cpu: 500m - memory: 128Mi + memory: 512Mi requests: cpu: 10m memory: 64Mi diff --git a/instrumentor/controllers/instrumentationdevice/pods_webhook.go b/instrumentor/controllers/instrumentationdevice/pods_webhook.go index 45fb0594c..cbf4f231c 100644 --- a/instrumentor/controllers/instrumentationdevice/pods_webhook.go +++ b/instrumentor/controllers/instrumentationdevice/pods_webhook.go @@ -3,22 +3,26 @@ package instrumentationdevice import ( "context" "fmt" + "strings" + common "github.com/odigos-io/odigos/common" "sigs.k8s.io/controller-runtime/pkg/webhook" corev1 "k8s.io/api/core/v1" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "k8s.io/apimachinery/pkg/runtime" ) +const ( + EnvVarNamespace = "ODIGOS_WORKLOAD_NAMESPACE" + EnvVarContainerName = "ODIGOS_CONTAINER_NAME" + EnvVarPodName = "ODIGOS_POD_NAME" +) + type PodsWebhook struct{} var _ webhook.CustomDefaulter = &PodsWebhook{} func (p *PodsWebhook) Default(ctx context.Context, obj runtime.Object) error { - // TODO(edenfed): add object selector to mutatingwebhookconfiguration - log := logf.FromContext(ctx) pod, ok := obj.(*corev1.Pod) if !ok { return fmt.Errorf("expected a Pod but got a %T", obj) @@ -28,7 +32,71 @@ func (p *PodsWebhook) Default(ctx context.Context, obj runtime.Object) error { pod.Annotations = map[string]string{} } - //pod.Annotations["odigos.io/instrumented-webhook"] = "true" - log.V(0).Info("Defaulted Pod", "name", pod.Name) + // Inject ODIGOS environment variables into all containers + injectOdigosEnvVars(pod) + return nil } + +func injectOdigosEnvVars(pod *corev1.Pod) { + namespace := pod.Namespace + + // Common environment variables that do not change across containers + commonEnvVars := []corev1.EnvVar{ + { + Name: EnvVarNamespace, + Value: namespace, + }, + { + Name: EnvVarPodName, + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: "metadata.name", + }, + }, + }, + } + + for i := range pod.Spec.Containers { + container := &pod.Spec.Containers[i] + + // Check if the container does NOT have device in conatiner limits. If so, skip the environment injection. + if !hasOdigosInstrumentationInLimits(container.Resources) { + continue + } + + // Check if the environment variables are already present, if so skip inject them again. + if envVarsExist(container.Env, commonEnvVars) { + continue + } + + container.Env = append(container.Env, append(commonEnvVars, corev1.EnvVar{ + Name: EnvVarContainerName, + Value: container.Name, + })...) + } +} + +func envVarsExist(containerEnv []corev1.EnvVar, commonEnvVars []corev1.EnvVar) bool { + envMap := make(map[string]struct{}) + for _, envVar := range containerEnv { + envMap[envVar.Name] = struct{}{} // Inserting empty struct as value + } + + for _, commonEnvVar := range commonEnvVars { + if _, exists := envMap[commonEnvVar.Name]; exists { // Checking if key exists + return true + } + } + return false +} + +// Helper function to check if a container's resource limits have a key starting with the specified namespace +func hasOdigosInstrumentationInLimits(resources corev1.ResourceRequirements) bool { + for resourceName := range resources.Limits { + if strings.HasPrefix(string(resourceName), common.OdigosResourceNamespace) { + return true + } + } + return false +} diff --git a/instrumentor/controllers/startlangdetection/manager.go b/instrumentor/controllers/startlangdetection/manager.go index 16b90af9c..e39e88ad7 100644 --- a/instrumentor/controllers/startlangdetection/manager.go +++ b/instrumentor/controllers/startlangdetection/manager.go @@ -1,6 +1,7 @@ package startlangdetection import ( + "github.com/odigos-io/odigos/instrumentor/controllers/utils" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -59,18 +60,18 @@ func SetupWithManager(mgr ctrl.Manager) error { return err } - //err = builder. - // ControllerManagedBy(mgr). - // Named("startlangdetection-configmaps"). - // For(&corev1.ConfigMap{}). - // WithEventFilter(&utils.OnlyUpdatesPredicate{}). - // Complete(&OdigosConfigReconciler{ - // Client: mgr.GetClient(), - // Scheme: mgr.GetScheme(), - // }) - //if err != nil { - // return err - //} + err = builder. + ControllerManagedBy(mgr). + Named("startlangdetection-configmaps"). + For(&corev1.ConfigMap{}). + WithEventFilter(&utils.OnlyUpdatesPredicate{}). + Complete(&OdigosConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }) + if err != nil { + return err + } return nil } diff --git a/instrumentor/main.go b/instrumentor/main.go index f892d1745..67a9494b9 100644 --- a/instrumentor/main.go +++ b/instrumentor/main.go @@ -20,6 +20,8 @@ import ( "flag" "os" + "github.com/odigos-io/odigos/k8sutils/pkg/env" + "github.com/odigos-io/odigos/instrumentor/controllers/instrumentationconfig" "github.com/odigos-io/odigos/instrumentor/controllers/startlangdetection" @@ -33,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -98,7 +101,7 @@ func main() { logger := zapr.NewLogger(zapLogger) ctrl.SetLogger(logger) - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + mgrOptions := ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ BindAddress: metricsAddr, @@ -171,9 +174,25 @@ func main() { &corev1.Namespace{}: { Label: labels.Set{consts.OdigosInstrumentationLabel: consts.InstrumentationEnabled}.AsSelector(), }, + &corev1.ConfigMap{}: { + Field: client.InNamespace(env.GetCurrentNamespace()).AsSelector(), + }, }, }, - }) + } + + // Check if the environment variable `LOCAL_WEBHOOK_CERT_DIR` is set. + // If defined, add WebhookServer options with the specified certificate directory. + // This is used primarily for local development environments to provide a custom path for serving TLS certificates. + localCertDir := os.Getenv("LOCAL_MUTATING_WEBHOOK_CERT_DIR") + if localCertDir != "" { + mgrOptions.WebhookServer = webhook.NewServer(webhook.Options{ + CertDir: localCertDir, + }) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOptions) + if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) diff --git a/k8sutils/pkg/workload/ownerreference.go b/k8sutils/pkg/workload/ownerreference.go index f5bacf863..fbb061ee9 100644 --- a/k8sutils/pkg/workload/ownerreference.go +++ b/k8sutils/pkg/workload/ownerreference.go @@ -1,36 +1,43 @@ package workload import ( - "errors" + "fmt" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// GetWorkloadFromOwnerReference retrieves both the workload name and workload kind +// from the provided owner reference. func GetWorkloadFromOwnerReference(ownerReference metav1.OwnerReference) (workloadName string, workloadKind WorkloadKind, err error) { - ownerName := ownerReference.Name - ownerKind := ownerReference.Kind + + return GetWorkloadNameAndKind(ownerReference.Name, ownerReference.Kind) +} + +func GetWorkloadNameAndKind(ownerName, ownerKind string) (string, WorkloadKind, error) { if ownerKind == "ReplicaSet" { - // ReplicaSet name is in the format - - hyphenIndex := strings.LastIndex(ownerName, "-") - if hyphenIndex == -1 { - // It is possible for a user to define a bare ReplicaSet without a deployment, currently not supporting this - err = errors.New("replicaset name does not contain a hyphen") - return - } - // Extract deployment name from ReplicaSet name - workloadName = ownerName[:hyphenIndex] - workloadKind = WorkloadKindDeployment - return + return extractDeploymentInfo(ownerName) + } + return handleNonReplicaSet(ownerName, ownerKind) +} + +// extractDeploymentInfo extracts deployment information from a ReplicaSet name +func extractDeploymentInfo(replicaSetName string) (string, WorkloadKind, error) { + hyphenIndex := strings.LastIndex(replicaSetName, "-") + if hyphenIndex == -1 { + return "", "", fmt.Errorf("replicaset name '%s' does not contain a hyphen", replicaSetName) } - workloadKind = WorkloadKindFromString(ownerKind) + deploymentName := replicaSetName[:hyphenIndex] + return deploymentName, WorkloadKindDeployment, nil +} + +// handleNonReplicaSet processes non-ReplicaSet workload types +func handleNonReplicaSet(ownerName, ownerKind string) (string, WorkloadKind, error) { + workloadKind := WorkloadKindFromString(ownerKind) if workloadKind == "" { - err = ErrKindNotSupported - return + return "", "", ErrKindNotSupported } - workloadName = ownerName - err = nil - return + return ownerName, workloadKind, nil } diff --git a/odiglet/pkg/ebpf/director.go b/odiglet/pkg/ebpf/director.go index 4855dd582..0d508056c 100644 --- a/odiglet/pkg/ebpf/director.go +++ b/odiglet/pkg/ebpf/director.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "reflect" "sync" "sync/atomic" "syscall" @@ -175,6 +176,11 @@ var IsProcessExists = func(pid int) bool { return false } +// Since OtelEbpfSdk is a generic type, we can't simply check it is nil with inst == nil +func isNil[T OtelEbpfSdk](inst T) bool { + return reflect.ValueOf(&inst).Elem().IsZero() +} + func (d *EbpfDirector[T]) periodicCleanup(ctx context.Context) { ticker := time.NewTicker(CleanupInterval) defer ticker.Stop() @@ -189,7 +195,10 @@ func (d *EbpfDirector[T]) periodicCleanup(ctx context.Context) { newInstrumentedProcesses := make([]*InstrumentedProcess[T], 0, len(details.InstrumentedProcesses)) for i := range details.InstrumentedProcesses { ip := details.InstrumentedProcesses[i] - if !IsProcessExists(ip.PID) && any(ip.inst) != nil { + // if the process does not exist, we should make sure we clean the instrumentation resources. + // Also making sure the instrumentation itself is not nil to avoid closing it here. + // This can happen if the process exits while the instrumentation is initializing. + if !IsProcessExists(ip.PID) && !isNil(ip.inst) { log.Logger.V(0).Info("Instrumented process does not exist, cleaning up", "pid", ip.PID) d.cleanProcess(ctx, pod, ip) } else { @@ -428,7 +437,7 @@ func (d *EbpfDirector[T]) GetWorkloadInstrumentations(workload *workload.PodWork } for _, ip := range details.InstrumentedProcesses { - if any(ip.inst) != nil { + if !isNil(ip.inst) { insts = append(insts, ip.inst) } } diff --git a/odiglet/pkg/ebpf/test/director_test.go b/odiglet/pkg/ebpf/director_test.go similarity index 70% rename from odiglet/pkg/ebpf/test/director_test.go rename to odiglet/pkg/ebpf/director_test.go index d8437f815..18baed701 100644 --- a/odiglet/pkg/ebpf/test/director_test.go +++ b/odiglet/pkg/ebpf/director_test.go @@ -1,4 +1,4 @@ -package test +package ebpf import ( "context" @@ -11,7 +11,6 @@ import ( "github.com/odigos-io/odigos/common" "github.com/odigos-io/odigos/k8sutils/pkg/instrumentation_instance" "github.com/odigos-io/odigos/k8sutils/pkg/workload" - "github.com/odigos-io/odigos/odiglet/pkg/ebpf" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -32,7 +31,7 @@ type FakeEbpfSdk struct { } // compile-time check that FakeEbpfSdk implements ConfigurableOtelEbpfSdk -var _ ebpf.ConfigurableOtelEbpfSdk = (*FakeEbpfSdk)(nil) +var _ ConfigurableOtelEbpfSdk = (*FakeEbpfSdk)(nil) func (f *FakeEbpfSdk) ApplyConfig(ctx context.Context, config *odigosv1.InstrumentationConfig) error { return nil @@ -63,24 +62,27 @@ func (f *FakeEbpfSdk) Run(ctx context.Context) error { } type FakeInstrumentationFactory struct { + timeToSetup time.Duration kubeclient client.Client } -func NewFakeInstrumentationFactory(kubeclient client.Client) ebpf.InstrumentationFactory[*FakeEbpfSdk] { +func NewFakeInstrumentationFactory(kubeclient client.Client, setupDuration time.Duration) InstrumentationFactory[*FakeEbpfSdk] { return &FakeInstrumentationFactory{ kubeclient: kubeclient, + timeToSetup: setupDuration, } } func (f *FakeInstrumentationFactory) CreateEbpfInstrumentation(ctx context.Context, pid int, serviceName string, podWorkload *workload.PodWorkload, containerName string, podName string, loadedIndicator chan struct{}) (*FakeEbpfSdk, error) { + <-time.After(f.timeToSetup) return &FakeEbpfSdk{ loadedIndicator: loadedIndicator, pid: pid, }, nil } -func newFakeDirector(ctx context.Context, client client.Client) ebpf.Director { - dir := ebpf.NewEbpfDirector(ctx, client, client.Scheme(), common.GoProgrammingLanguage, NewFakeInstrumentationFactory(client)) +func newFakeDirector(ctx context.Context, client client.Client, setupDuration time.Duration) Director { + dir := NewEbpfDirector(ctx, client, client.Scheme(), common.GoProgrammingLanguage, NewFakeInstrumentationFactory(client, setupDuration)) return dir } @@ -113,7 +115,7 @@ func assertHealthyInstrumentationInstance(t *testing.T, client client.Client, po return assert.False(t, *instance.Status.Healthy) } -func assertInstrumentationInstanceDeleted(t *testing.T, client client.Client, pod types.NamespacedName, pid int) bool { +func assertInstrumentationInstanceNotExisting(t *testing.T, client client.Client, pod types.NamespacedName, pid int) bool { // instrumentation instance is deleted return assert.Eventually(t, func() bool { return getInstrumentationInstance(client, pod, pid) == nil @@ -145,15 +147,15 @@ func TestSingleInstrumentation(t *testing.T) { WithRuntimeObjects(&pod). Build() - origIsProcessExists := ebpf.IsProcessExists - ebpf.IsProcessExists = func(pid int) bool { + origIsProcessExists := IsProcessExists + IsProcessExists = func(pid int) bool { return true } - t.Cleanup(func() { ebpf.IsProcessExists = origIsProcessExists }) + t.Cleanup(func() { IsProcessExists = origIsProcessExists }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - dir := newFakeDirector(ctx, client).(*ebpf.EbpfDirector[*FakeEbpfSdk]) + dir := newFakeDirector(ctx, client, time.Millisecond).(*EbpfDirector[*FakeEbpfSdk]) err := dir.Instrument(ctx, 1, pod_id, workload, "test-app", "test-container") assert.NoError(t, err) @@ -171,7 +173,7 @@ func TestSingleInstrumentation(t *testing.T) { // cleanup dir.Cleanup(pod_id) // the instrumentation instance is deleted - if !assertInstrumentationInstanceDeleted(t, client, pod_id, 1) { + if !assertInstrumentationInstanceNotExisting(t, client, pod_id, 1) { t.FailNow() } @@ -210,18 +212,18 @@ func TestInstrumentNotExistingProcess(t *testing.T) { WithRuntimeObjects(&pod). Build() - origIsProcessExists := ebpf.IsProcessExists - ebpf.IsProcessExists = func(pid int) bool { + origIsProcessExists := IsProcessExists + IsProcessExists = func(pid int) bool { return true } - t.Cleanup(func() { ebpf.IsProcessExists = origIsProcessExists }) + t.Cleanup(func() { IsProcessExists = origIsProcessExists }) // setup the cleanup interval to be very short for the test to be responsive - origCleanupInterval := ebpf.CleanupInterval - ebpf.CleanupInterval = 10 * time.Millisecond - t.Cleanup(func() { ebpf.CleanupInterval = origCleanupInterval }) + origCleanupInterval := CleanupInterval + CleanupInterval = 10 * time.Millisecond + t.Cleanup(func() { CleanupInterval = origCleanupInterval }) - dir := newFakeDirector(ctx, client).(*ebpf.EbpfDirector[*FakeEbpfSdk]) + dir := newFakeDirector(ctx, client, time.Millisecond).(*EbpfDirector[*FakeEbpfSdk]) err := dir.Instrument(ctx, 1, pod_id, workload, "test-app", "test-container") assert.NoError(t, err) @@ -238,11 +240,11 @@ func TestInstrumentNotExistingProcess(t *testing.T) { assert.False(t, inst.closed) // "kill" the process - ebpf.IsProcessExists = func(pid int) bool { + IsProcessExists = func(pid int) bool { return false } // the instrumentation instance is deleted - if !assertInstrumentationInstanceDeleted(t, client, pod_id, 1) { + if !assertInstrumentationInstanceNotExisting(t, client, pod_id, 1) { t.FailNow() } // the director stopped tracking the instrumentation @@ -254,6 +256,67 @@ func TestInstrumentNotExistingProcess(t *testing.T) { assert.True(t, inst.closed) } +func TestInstrumentNotExistingProcessWithSlowInstrumentation(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + corev1.AddToScheme(scheme) + odigosv1.AddToScheme(scheme) + + workload := &workload.PodWorkload{ + Name: "test-workload", + Namespace: "default", + Kind: "Deployment", + } + pod_id := types.NamespacedName{Name: "test", Namespace: "default"} + pod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: pod_id.Name, + Namespace: pod_id.Namespace, + }, + } + + client := fake. + NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&odigosv1.InstrumentationInstance{}). + WithRuntimeObjects(&pod). + Build() + + origIsProcessExists := IsProcessExists + IsProcessExists = func(pid int) bool { + return true + } + t.Cleanup(func() { IsProcessExists = origIsProcessExists }) + + // setup the cleanup interval to be very short for the test to be responsive + origCleanupInterval := CleanupInterval + CleanupInterval = 10 * time.Millisecond + t.Cleanup(func() { CleanupInterval = origCleanupInterval }) + + dir := newFakeDirector(ctx, client, time.Second).(*EbpfDirector[*FakeEbpfSdk]) + err := dir.Instrument(ctx, 1, pod_id, workload, "test-app", "test-container") + assert.NoError(t, err) + + <-time.After(100 * time.Millisecond) + // "kill" the process while the instrumentation is still setting up + IsProcessExists = func(pid int) bool { + return false + } + + // wait for the instrumentation to initialize + <-time.After(1 * time.Second) + + // the instrumentation instance is not existing + if !assertInstrumentationInstanceNotExisting(t, client, pod_id, 1) { + t.FailNow() + } + // the director stopped tracking the instrumentation + insts := dir.GetWorkloadInstrumentations(workload) + if !assert.Len(t, insts, 0) { + t.FailNow() + } +} + func TestMultiplePodsInstrumentation(t *testing.T) { ctx := context.Background() scheme := runtime.NewScheme() @@ -287,13 +350,13 @@ func TestMultiplePodsInstrumentation(t *testing.T) { WithLists(&podList). Build() - origIsProcessExists := ebpf.IsProcessExists - ebpf.IsProcessExists = func(pid int) bool { + origIsProcessExists := IsProcessExists + IsProcessExists = func(pid int) bool { return true } - t.Cleanup(func() { ebpf.IsProcessExists = origIsProcessExists }) + t.Cleanup(func() { IsProcessExists = origIsProcessExists }) - dir := newFakeDirector(ctx, client).(*ebpf.EbpfDirector[*FakeEbpfSdk]) + dir := newFakeDirector(ctx, client, time.Millisecond).(*EbpfDirector[*FakeEbpfSdk]) for i := 0; i < numOfPods; i++ { err := dir.Instrument(ctx, i+1, pod_ids[i], workload, "test-app", "test-container") assert.NoError(t, err) @@ -327,7 +390,7 @@ func TestMultiplePodsInstrumentation(t *testing.T) { // the instrumentation instances are deleted for i := 0; i < numOfPods - 1; i++ { - if !assertInstrumentationInstanceDeleted(t, client, pod_ids[i], i+1) { + if !assertInstrumentationInstanceNotExisting(t, client, pod_ids[i], i+1) { t.FailNow() } } @@ -354,3 +417,14 @@ func TestMultiplePodsInstrumentation(t *testing.T) { // The last instrumentation is the one returned assert.Equal(t, insts[0].pid, numOfPods) } + +func TestIsNil(t *testing.T) { + var e OtelEbpfSdk + assert.True(t, isNil(e)) + + e = &FakeEbpfSdk{} + assert.False(t, isNil(e)) + + var e2 *FakeEbpfSdk + assert.True(t, isNil(e2)) +} diff --git a/opampserver/pkg/server/handlers.go b/opampserver/pkg/server/handlers.go index ca4af1045..1d8092689 100644 --- a/opampserver/pkg/server/handlers.go +++ b/opampserver/pkg/server/handlers.go @@ -11,24 +11,35 @@ import ( "github.com/odigos-io/odigos/k8sutils/pkg/instrumentation_instance" "github.com/odigos-io/odigos/k8sutils/pkg/workload" "github.com/odigos-io/odigos/opampserver/pkg/connection" - "github.com/odigos-io/odigos/opampserver/pkg/deviceid" + di "github.com/odigos-io/odigos/opampserver/pkg/deviceid" "github.com/odigos-io/odigos/opampserver/pkg/sdkconfig" "github.com/odigos-io/odigos/opampserver/pkg/sdkconfig/configresolvers" "github.com/odigos-io/odigos/opampserver/protobufs" semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" "sigs.k8s.io/controller-runtime/pkg/client" ) type ConnectionHandlers struct { - deviceIdCache *deviceid.DeviceIdCache + deviceIdCache *di.DeviceIdCache sdkConfig *sdkconfig.SdkConfigManager logger logr.Logger kubeclient client.Client + kubeClientSet *kubernetes.Clientset scheme *runtime.Scheme // TODO: revisit this, we should not depend on controller runtime nodeName string } +type opampAgentAttributesKeys struct { + ProgrammingLanguage string + ContainerName string + PodName string + Namespace string +} + func (c *ConnectionHandlers) OnNewConnection(ctx context.Context, deviceId string, firstMessage *protobufs.AgentToServer) (*connection.ConnectionInfo, *protobufs.ServerToAgent, error) { if firstMessage.AgentDescription == nil { @@ -53,25 +64,19 @@ func (c *ConnectionHandlers) OnNewConnection(ctx context.Context, deviceId strin return nil, nil, fmt.Errorf("missing pid in agent description") } - var programmingLanguage string - for _, attr := range firstMessage.AgentDescription.IdentifyingAttributes { - if attr.Key == string(semconv.TelemetrySDKLanguageKey) { - programmingLanguage = attr.Value.GetStringValue() - break - } - } - if programmingLanguage == "" { + attrs := extractOpampAgentAttributes(firstMessage.AgentDescription) + + if attrs.ProgrammingLanguage == "" { return nil, nil, fmt.Errorf("missing programming language in agent description") } - k8sAttributes, pod, err := c.deviceIdCache.GetAttributesFromDevice(ctx, deviceId) + k8sAttributes, pod, err := c.resolveK8sAttributes(ctx, attrs, deviceId, c.logger) if err != nil { - c.logger.Error(err, "failed to get attributes from device", "deviceId", deviceId) - return nil, nil, err + return nil, nil, fmt.Errorf("failed to process k8s attributes: %w", err) } podWorkload := workload.PodWorkload{ - Namespace: pod.GetNamespace(), + Namespace: k8sAttributes.Namespace, Kind: workload.WorkloadKind(k8sAttributes.WorkloadKind), Name: k8sAttributes.WorkloadName, } @@ -83,7 +88,7 @@ func (c *ConnectionHandlers) OnNewConnection(ctx context.Context, deviceId strin return nil, nil, err } - fullRemoteConfig, err := c.sdkConfig.GetFullConfig(ctx, remoteResourceAttributes, &podWorkload, instrumentedAppName, programmingLanguage) + fullRemoteConfig, err := c.sdkConfig.GetFullConfig(ctx, remoteResourceAttributes, &podWorkload, instrumentedAppName, attrs.ProgrammingLanguage) if err != nil { c.logger.Error(err, "failed to get full config", "k8sAttributes", k8sAttributes) return nil, nil, err @@ -96,7 +101,7 @@ func (c *ConnectionHandlers) OnNewConnection(ctx context.Context, deviceId strin Pod: pod, ContainerName: k8sAttributes.ContainerName, Pid: pid, - ProgrammingLanguage: programmingLanguage, + ProgrammingLanguage: attrs.ProgrammingLanguage, InstrumentedAppName: instrumentedAppName, AgentRemoteConfig: fullRemoteConfig, RemoteResourceAttributes: remoteResourceAttributes, @@ -191,3 +196,72 @@ func (c *ConnectionHandlers) UpdateInstrumentationInstanceStatus(ctx context.Con return nil } + +// resolveK8sAttributes resolves K8s resource attributes using either direct attributes from opamp agent or device cache +func (c *ConnectionHandlers) resolveK8sAttributes(ctx context.Context, attrs opampAgentAttributesKeys, + deviceId string, logger logr.Logger) (*di.K8sResourceAttributes, *corev1.Pod, error) { + + if attrs.hasRequiredAttributes() { + podInfoResolver := di.NewK8sPodInfoResolver(logger, c.kubeClientSet) + return resolveFromDirectAttributes(ctx, attrs, podInfoResolver, c.kubeClientSet) + } + return c.deviceIdCache.GetAttributesFromDevice(ctx, deviceId) +} + +func extractOpampAgentAttributes(agentDescription *protobufs.AgentDescription) opampAgentAttributesKeys { + result := opampAgentAttributesKeys{} + + for _, attr := range agentDescription.IdentifyingAttributes { + switch attr.Key { + case string(semconv.TelemetrySDKLanguageKey): + result.ProgrammingLanguage = attr.Value.GetStringValue() + case string(semconv.K8SContainerNameKey): + result.ContainerName = attr.Value.GetStringValue() + case string(semconv.K8SPodNameKey): + result.PodName = attr.Value.GetStringValue() + case string(semconv.K8SNamespaceNameKey): + result.Namespace = attr.Value.GetStringValue() + } + } + + return result +} + +func (k opampAgentAttributesKeys) hasRequiredAttributes() bool { + return k.ContainerName != "" && k.PodName != "" && k.Namespace != "" +} + +func resolveFromDirectAttributes(ctx context.Context, attrs opampAgentAttributesKeys, + podInfoResolver *di.K8sPodInfoResolver, kubeClient *kubernetes.Clientset) (*di.K8sResourceAttributes, *corev1.Pod, error) { + + pod, err := kubeClient.CoreV1().Pods(attrs.Namespace).Get(ctx, attrs.PodName, metav1.GetOptions{}) + if err != nil { + return nil, nil, err + } + + var workloadName string + var workloadKind workload.WorkloadKind + + ownerRefs := pod.GetOwnerReferences() + for _, ownerRef := range ownerRefs { + workloadName, workloadKind, err = workload.GetWorkloadFromOwnerReference(ownerRef) + if err != nil { + return nil, nil, fmt.Errorf("failed to get workload from owner reference: %w", err) + } + } + + serviceName := podInfoResolver.ResolveServiceName(ctx, workloadName, string(workloadKind), &di.ContainerDetails{ + PodNamespace: attrs.Namespace, + }) + + k8sAttributes := &di.K8sResourceAttributes{ + Namespace: attrs.Namespace, + PodName: attrs.PodName, + ContainerName: attrs.ContainerName, + WorkloadKind: string(workloadKind), + WorkloadName: workloadName, + OtelServiceName: serviceName, + } + + return k8sAttributes, pod, nil +} diff --git a/opampserver/pkg/server/server.go b/opampserver/pkg/server/server.go index 4d8f0fcfb..c8c6b0dbf 100644 --- a/opampserver/pkg/server/server.go +++ b/opampserver/pkg/server/server.go @@ -17,12 +17,12 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) -func StartOpAmpServer(ctx context.Context, logger logr.Logger, mgr ctrl.Manager, kubeClient *kubernetes.Clientset, nodeName string, odigosNs string) error { +func StartOpAmpServer(ctx context.Context, logger logr.Logger, mgr ctrl.Manager, kubeClientSet *kubernetes.Clientset, nodeName string, odigosNs string) error { listenEndpoint := fmt.Sprintf("0.0.0.0:%d", OpAmpServerDefaultPort) logger.Info("Starting opamp server", "listenEndpoint", listenEndpoint) - deviceidCache, err := deviceid.NewDeviceIdCache(logger, kubeClient) + deviceidCache, err := deviceid.NewDeviceIdCache(logger, kubeClientSet) if err != nil { return err } @@ -36,6 +36,7 @@ func StartOpAmpServer(ctx context.Context, logger logr.Logger, mgr ctrl.Manager, deviceIdCache: deviceidCache, sdkConfig: sdkConfig, kubeclient: mgr.GetClient(), + kubeClientSet: kubeClientSet, scheme: mgr.GetScheme(), nodeName: nodeName, } @@ -116,7 +117,11 @@ func StartOpAmpServer(ctx context.Context, logger logr.Logger, mgr ctrl.Manager, } if isAgentDisconnect { - logger.Info("Agent disconnected", "workloadNamespace", connectionInfo.Workload.Namespace, "workloadName", connectionInfo.Workload.Name, "workloadKind", connectionInfo.Workload.Kind) + + // This may occurs when Odiglet restarts, and a previously connected pod sends a disconnect message right after reconnecting. + if connectionInfo != nil { + logger.Info("Agent disconnected", "workloadNamespace", connectionInfo.Workload.Namespace, "workloadName", connectionInfo.Workload.Name, "workloadKind", connectionInfo.Workload.Kind) + } // if agent disconnects, remove the connection from the cache // as it is not expected to send additional messages connectionCache.RemoveConnection(instanceUid) diff --git a/tests/common/traceql_runner.sh b/tests/common/traceql_runner.sh index a77d02f6d..df7eb129e 100755 --- a/tests/common/traceql_runner.sh +++ b/tests/common/traceql_runner.sh @@ -47,7 +47,6 @@ function process_yaml_file() { # if num_of_traces not equal to expected_count if [ "$num_of_traces" -ne "$expected_count" ]; then echo "Test FAILED: expected $expected_count got $num_of_traces" - echo "$response" | jq exit 1 else echo "Test PASSED" diff --git a/tests/e2e/README.md b/tests/e2e/README.md index c6c9bce1e..5948adbc2 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -133,8 +133,15 @@ When tests fail, and it's related to some traceql query not succeeding, it can b - Install grafana with helm: ```bash -helm install -n traces grafana grafana/grafana --set adminPassword='odigos' -``` +helm install -n traces grafana grafana/grafana \ + --set "env.GF_AUTH_ANONYMOUS_ENABLED=true" \ + --set "env.GF_AUTH_ANONYMOUS_ORG_ROLE=Admin" \ + --set "datasources.datasources\.yaml.apiVersion=1" \ + --set "datasources.datasources\.yaml.datasources[0].name=Tempo" \ + --set "datasources.datasources\.yaml.datasources[0].type=tempo" \ + --set "datasources.datasources\.yaml.datasources[0].url=http://e2e-tests-tempo:3100" \ + --set "datasources.datasources\.yaml.datasources[0].access=proxy" \ + --set "datasources.datasources\.yaml.datasources[0].isDefault=true"``` - Port forward to the grafana service: @@ -142,11 +149,9 @@ helm install -n traces grafana grafana/grafana --set adminPassword='odigos' kubectl port-forward svc/grafana 3080:80 -n traces ``` -- Browse to `http://localhost:3080` and login with `admin` and `odigos`. - -- Add tempo as a datasource, by going to `Connections -> Data Sources -> Add data source` and selecting `Tempo` as the type. Set the URL to `http://e2e-tests-tempo:3100` and save. +- Browse to `http://localhost:3080/explore`. -- In grafana left side menu, go to `Explore` and select the tempo datasource. You can now write queries, run them, and see the traces that are stored in tempo to troubleshoot your test issues. example query: +- You can now write queries, run them, and see the traces that are stored in tempo to troubleshoot your test issues. example query: ``` {resource.service.name = "coupon"} diff --git a/tests/e2e/workload-lifecycle/01-assert-apps-installed.yaml b/tests/e2e/workload-lifecycle/01-assert-apps-installed.yaml index c5c739814..35b3666b4 100644 --- a/tests/e2e/workload-lifecycle/01-assert-apps-installed.yaml +++ b/tests/e2e/workload-lifecycle/01-assert-apps-installed.yaml @@ -194,4 +194,82 @@ status: restartCount: 0 started: true phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + app: python-latest-version + namespace: default +spec: + containers: + - env: + - name: DJANGO_SETTINGS_MODULE + (value != null): true +status: + containerStatuses: + - name: python-latest-version + ready: true + restartCount: 0 + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + app: python-alpine + namespace: default +spec: + containers: + - env: + - name: DJANGO_SETTINGS_MODULE + (value != null): true + - name: PYTHONPATH + (value != null): true +status: + containerStatuses: + - name: python-alpine + ready: true + restartCount: 0 + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + app: python-min-version + namespace: default +spec: + containers: + - env: + - name: DJANGO_SETTINGS_MODULE + (value != null): true +status: + containerStatuses: + - name: python-min-version + ready: true + restartCount: 0 + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + app: python-not-supported + namespace: default +spec: + containers: + - env: + - name: DJANGO_SETTINGS_MODULE + (value != null): true +status: + containerStatuses: + - name: python-not-supported + ready: true + restartCount: 0 + started: true + phase: Running --- \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/01-assert-instrumented.yaml b/tests/e2e/workload-lifecycle/01-assert-instrumented.yaml index 3c5c69e53..527003a5f 100644 --- a/tests/e2e/workload-lifecycle/01-assert-instrumented.yaml +++ b/tests/e2e/workload-lifecycle/01-assert-instrumented.yaml @@ -273,4 +273,108 @@ status: reason: InstrumentationDeviceApplied status: "True" type: AppliedInstrumentationDevice ---- \ No newline at end of file +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + namespace: default + name: deployment-python-alpine +spec: + runtimeDetails: + - containerName: python-alpine +status: + conditions: + - message: "Instrumentation device applied successfully" + observedGeneration: 1 + reason: InstrumentationDeviceApplied + status: "True" + type: AppliedInstrumentationDevice +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + namespace: default + name: deployment-python-latest-version +status: + conditions: + - message: "Instrumentation device applied successfully" + observedGeneration: 1 + reason: InstrumentationDeviceApplied + status: "True" + type: AppliedInstrumentationDevice +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + namespace: default + name: deployment-python-min-version +status: + conditions: + - message: "Instrumentation device applied successfully" + observedGeneration: 1 + reason: InstrumentationDeviceApplied + status: "True" + type: AppliedInstrumentationDevice +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + namespace: default + name: deployment-python-not-supported +status: + conditions: + - message: "python runtime version not supported by OpenTelemetry SDK. Found: 3.6.15, supports: 3.8.0" + observedGeneration: 1 + reason: RuntimeVersionNotSupported + status: "False" + type: AppliedInstrumentationDevice + + +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentationInstance +metadata: + namespace: default + labels: + instrumented-app: deployment-python-alpine +status: + healthy: true + identifyingAttributes: + - key: service.instance.id + (value != null): true + - key: process.pid + (value != null): true + - key: telemetry.sdk.language + value: python +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentationInstance +metadata: + namespace: default + labels: + instrumented-app: deployment-python-latest-version +status: + healthy: true + identifyingAttributes: + - key: service.instance.id + (value != null): true + - key: process.pid + (value != null): true + - key: telemetry.sdk.language + value: python +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentationInstance +metadata: + namespace: default + labels: + instrumented-app: deployment-python-min-version +status: + healthy: true + identifyingAttributes: + - key: service.instance.id + (value != null): true + - key: process.pid + (value != null): true + - key: telemetry.sdk.language + value: python \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/01-assert-runtime-detected.yaml b/tests/e2e/workload-lifecycle/01-assert-runtime-detected.yaml index 42a62b05f..bf0b38a33 100644 --- a/tests/e2e/workload-lifecycle/01-assert-runtime-detected.yaml +++ b/tests/e2e/workload-lifecycle/01-assert-runtime-detected.yaml @@ -248,4 +248,75 @@ spec: runtimeDetails: - containerName: java-old-version language: java ---- \ No newline at end of file +--- +## Python +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + name: deployment-python-alpine + namespace: default + ownerReferences: + - apiVersion: apps/v1 + blockOwnerDeletion: true + controller: true + kind: Deployment + name: python-alpine +spec: + runtimeDetails: + - containerName: python-alpine + envVars: + - name: PYTHONPATH + value: "/app" + language: python + runtimeVersion: 3.10.15 +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + name: deployment-python-latest-version + namespace: default + ownerReferences: + - apiVersion: apps/v1 + blockOwnerDeletion: true + controller: true + kind: Deployment + name: python-latest-version +spec: + runtimeDetails: + - containerName: python-latest-version + language: python + (runtimeVersion != null): true +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + name: deployment-python-min-version + namespace: default + ownerReferences: + - apiVersion: apps/v1 + blockOwnerDeletion: true + controller: true + kind: Deployment + name: python-min-version +spec: + runtimeDetails: + - containerName: python-min-version + language: python + runtimeVersion: 3.8.0 +--- +apiVersion: odigos.io/v1alpha1 +kind: InstrumentedApplication +metadata: + name: deployment-python-not-supported + namespace: default + ownerReferences: + - apiVersion: apps/v1 + blockOwnerDeletion: true + controller: true + kind: Deployment + name: python-not-supported +spec: + runtimeDetails: + - containerName: python-not-supported + language: python + runtimeVersion: 3.6.15 \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/01-assert-workloads.yaml b/tests/e2e/workload-lifecycle/01-assert-workloads.yaml index da7d3a206..be4488f08 100644 --- a/tests/e2e/workload-lifecycle/01-assert-workloads.yaml +++ b/tests/e2e/workload-lifecycle/01-assert-workloads.yaml @@ -428,4 +428,122 @@ status: observedGeneration: 2 readyReplicas: 1 replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "2" # the deployment spec changed when odigos resource was added + generation: 2 # the deployment spec changed when odigos resource was added + labels: + app: python-latest-version + name: python-latest-version + namespace: default +spec: + selector: + matchLabels: + app: python-latest-version + template: + spec: + containers: + - image: python-latest-version:v0.0.1 + name: python-latest-version + resources: + limits: + instrumentation.odigos.io/python-native-community: "1" +status: + availableReplicas: 1 + observedGeneration: 2 # the deployment spec changed when odigos resource was added + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "2" # the deployment spec changed when odigos resource was added + generation: 2 # the deployment spec changed when odigos resource was added + labels: + app: python-alpine + name: python-alpine + namespace: default +spec: + selector: + matchLabels: + app: python-alpine + template: + spec: + containers: + - image: python-alpine:v0.0.1 + name: python-alpine + resources: + limits: + instrumentation.odigos.io/python-native-community: "1" + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + - name: PYTHONPATH + value: "/app:/var/odigos/python:/var/odigos/python/opentelemetry/instrumentation/auto_instrumentation" +status: + availableReplicas: 1 + observedGeneration: 2 # the deployment spec changed when odigos resource was added + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + generation: 1 + labels: + app: python-not-supported + name: python-not-supported + namespace: default +spec: + selector: + matchLabels: + app: python-not-supported + template: + spec: + containers: + - image: python-not-supported:v0.0.1 + name: python-not-supported +status: + availableReplicas: 1 + observedGeneration: 1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "2" # the deployment spec changed when odigos resource was added + generation: 2 # the deployment spec changed when odigos resource was added + labels: + app: python-min-version + name: python-min-version + namespace: default +spec: + selector: + matchLabels: + app: python-min-version + template: + spec: + containers: + - image: python-min-version:v0.0.1 + name: python-min-version + resources: + limits: + instrumentation.odigos.io/python-native-community: "1" +status: + availableReplicas: 1 + observedGeneration: 2 # the deployment spec changed when odigos resource was added + readyReplicas: 1 + replicas: 1 updatedReplicas: 1 \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/01-generate-traffic.yaml b/tests/e2e/workload-lifecycle/01-generate-traffic.yaml index 63c5ab05f..5aa7bd7af 100644 --- a/tests/e2e/workload-lifecycle/01-generate-traffic.yaml +++ b/tests/e2e/workload-lifecycle/01-generate-traffic.yaml @@ -29,11 +29,19 @@ spec: curl -s --fail http://nodejs-latest-version:3000 curl -s --fail http://nodejs-dockerfile-env:3000 curl -s --fail http://nodejs-manifest-env:3000 + curl -s --fail http://cpp-http-server:3000 + curl -s --fail http://language-change:3000 + curl -s --fail http://java-supported-version:3000 curl -s --fail http://java-supported-docker-env:3000 curl -s --fail http://java-azul:3000 curl -s --fail http://java-supported-manifest-env:3000 curl -s --fail http://java-latest-version:3000 curl -s --fail http://java-old-version:3000 + + curl -s --fail http://python-alpine:3000/insert-random/ + curl -s --fail http://python-latest-version:3000/insert-random/ + curl -s --fail http://python-min-version:3000/insert-random/ + curl -s --fail http://python-not-supported:3000/insert-random/ diff --git a/tests/e2e/workload-lifecycle/01-install-test-apps.yaml b/tests/e2e/workload-lifecycle/01-install-test-apps.yaml index 1257a17b4..c91317dfb 100644 --- a/tests/e2e/workload-lifecycle/01-install-test-apps.yaml +++ b/tests/e2e/workload-lifecycle/01-install-test-apps.yaml @@ -521,4 +521,197 @@ spec: ports: - protocol: TCP port: 3000 ---- \ No newline at end of file +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-latest-version + namespace: default + labels: + app: python-latest-version +spec: + selector: + matchLabels: + app: python-latest-version + template: + metadata: + labels: + app: python-latest-version + spec: + containers: + - name: python-latest-version + image: python-latest-version:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Service +apiVersion: v1 +metadata: + name: python-latest-version + namespace: default +spec: + selector: + app: python-latest-version + ports: + - protocol: TCP + port: 3000 + targetPort: 8000 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-alpine + namespace: default + labels: + app: python-alpine +spec: + selector: + matchLabels: + app: python-alpine + template: + metadata: + labels: + app: python-alpine + spec: + containers: + - name: python-alpine + image: python-alpine:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + - name: PYTHONPATH + value: "/app" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Service +apiVersion: v1 +metadata: + name: python-alpine + namespace: default +spec: + selector: + app: python-alpine + ports: + - protocol: TCP + port: 3000 + targetPort: 8000 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-not-supported + namespace: default + labels: + app: python-not-supported +spec: + selector: + matchLabels: + app: python-not-supported + template: + metadata: + labels: + app: python-not-supported + spec: + containers: + - name: python-not-supported + image: python-not-supported:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Service +apiVersion: v1 +metadata: + name: python-not-supported + namespace: default +spec: + selector: + app: python-not-supported + ports: + - protocol: TCP + port: 3000 + targetPort: 8000 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-min-version + namespace: default + labels: + app: python-min-version +spec: + selector: + matchLabels: + app: python-min-version + template: + metadata: + labels: + app: python-min-version + spec: + containers: + - name: python-min-version + image: python-min-version:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Service +apiVersion: v1 +metadata: + name: python-min-version + namespace: default +spec: + selector: + app: python-min-version + ports: + - protocol: TCP + port: 3000 + targetPort: 8000 \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/01-wait-for-trace.yaml b/tests/e2e/workload-lifecycle/01-wait-for-trace.yaml index 19a88c112..fedeb6d17 100644 --- a/tests/e2e/workload-lifecycle/01-wait-for-trace.yaml +++ b/tests/e2e/workload-lifecycle/01-wait-for-trace.yaml @@ -12,6 +12,10 @@ query: | { resource.service.name = "java-old-version" } || { resource.service.name = "java-supported-docker-env" } || { resource.service.name = "java-supported-manifest-env" } || - { resource.service.name = "java-azul" } + { resource.service.name = "java-azul" } || + { resource.service.name = "python-latest-version" && span.http.route = "insert-random/" } || + { resource.service.name = "python-alpine" && span.http.route = "insert-random/" } || + { resource.service.name = "python-not-supported" && span.http.route = "insert-random/" } || + { resource.service.name = "python-min-version" && span.http.route = "insert-random/" } expected: - count: 11 + count: 14 diff --git a/tests/e2e/workload-lifecycle/02-assert-workload-update.yaml b/tests/e2e/workload-lifecycle/02-assert-workload-update.yaml index 98294993e..345692fb7 100644 --- a/tests/e2e/workload-lifecycle/02-assert-workload-update.yaml +++ b/tests/e2e/workload-lifecycle/02-assert-workload-update.yaml @@ -375,4 +375,110 @@ status: observedGeneration: 4 readyReplicas: 1 replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + generation: 4 # on step 2, the manifest was updated (1->2) + labels: + app: python-latest-version + name: python-latest-version + namespace: default +spec: + selector: + matchLabels: + app: python-latest-version + template: + spec: + containers: + - image: python-latest-version:v0.0.1 + name: python-latest-version + resources: + limits: + instrumentation.odigos.io/python-native-community: "1" +status: + availableReplicas: 1 + observedGeneration: 4 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + generation: 4 # on step 2, the manifest was updated (1->2) + labels: + app: python-alpine + name: python-alpine + namespace: default +spec: + selector: + matchLabels: + app: python-alpine + template: + spec: + containers: + - image: python-alpine:v0.0.1 + name: python-alpine + resources: + limits: + instrumentation.odigos.io/python-native-community: "1" +status: + availableReplicas: 1 + observedGeneration: 4 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + generation: 4 # on step 2, the manifest was updated (1->2) + labels: + app: python-min-version + name: python-min-version + namespace: default +spec: + selector: + matchLabels: + app: python-min-version + template: + spec: + containers: + - image: python-min-version:v0.0.1 + name: python-min-version + resources: + limits: + instrumentation.odigos.io/python-native-community: "1" +status: + availableReplicas: 1 + observedGeneration: 4 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + generation: 2 # on step 2, the manifest was updated (1->2) + labels: + app: python-not-supported + name: python-not-supported + namespace: default +spec: + selector: + matchLabels: + app: python-not-supported + template: + spec: + containers: + - image: python-not-supported:v0.0.1 + name: python-not-supported + resources: {} +status: + availableReplicas: 1 + observedGeneration: 2 + readyReplicas: 1 + replicas: 1 updatedReplicas: 1 \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/02-update-workload-manifests.yaml b/tests/e2e/workload-lifecycle/02-update-workload-manifests.yaml index b34785929..062681ba4 100644 --- a/tests/e2e/workload-lifecycle/02-update-workload-manifests.yaml +++ b/tests/e2e/workload-lifecycle/02-update-workload-manifests.yaml @@ -376,4 +376,153 @@ spec: readinessProbe: tcpSocket: port: 3000 - initialDelaySeconds: 20 \ No newline at end of file + initialDelaySeconds: 20 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-latest-version + namespace: default + labels: + app: python-latest-version +spec: + selector: + matchLabels: + app: python-latest-version + template: + metadata: + labels: + app: python-latest-version + annotations: + odigos-test-step: "2" + spec: + containers: + - name: python-latest-version + image: python-latest-version:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-alpine + namespace: default + labels: + app: python-alpine +spec: + selector: + matchLabels: + app: python-alpine + template: + metadata: + labels: + app: python-alpine + annotations: + odigos-test-step: "2" + spec: + containers: + - name: python-alpine + image: python-alpine:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-not-supported + namespace: default + labels: + app: python-not-supported +spec: + selector: + matchLabels: + app: python-not-supported + template: + metadata: + labels: + app: python-not-supported + annotations: + odigos-test-step: "2" + spec: + containers: + - name: python-not-supported + image: python-not-supported:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: python-min-version + namespace: default + labels: + app: python-min-version +spec: + selector: + matchLabels: + app: python-min-version + template: + metadata: + labels: + app: python-min-version + annotations: + odigos-test-step: "2" + spec: + containers: + - name: python-min-version + image: python-min-version:v0.0.1 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DJANGO_SETTINGS_MODULE + value: "myapp.settings" + livenessProbe: + httpGet: + path: /health/ + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 +--- diff --git a/tests/e2e/workload-lifecycle/02-wait-for-trace.yaml b/tests/e2e/workload-lifecycle/02-wait-for-trace.yaml index a984e8109..2b25b6e35 100644 --- a/tests/e2e/workload-lifecycle/02-wait-for-trace.yaml +++ b/tests/e2e/workload-lifecycle/02-wait-for-trace.yaml @@ -12,6 +12,10 @@ query: | { resource.service.name = "java-old-version" } || { resource.service.name = "java-supported-docker-env" } || { resource.service.name = "java-supported-manifest-env" } || - { resource.service.name = "java-azul" } + { resource.service.name = "java-azul" } || + { resource.service.name = "python-latest-version" && span.http.route = "insert-random/" } || + { resource.service.name = "python-alpine" && span.http.route = "insert-random/" } || + { resource.service.name = "python-not-supported" && span.http.route = "insert-random/" } || + { resource.service.name = "python-min-version" && span.http.route = "insert-random/" } expected: - count: 21 # 11 before +10 new ones + count: 27 # 14 before +13 new ones diff --git a/tests/e2e/workload-lifecycle/README.md b/tests/e2e/workload-lifecycle/README.md index e51cd0123..d8336a5d6 100644 --- a/tests/e2e/workload-lifecycle/README.md +++ b/tests/e2e/workload-lifecycle/README.md @@ -78,6 +78,28 @@ This e2e test verify various scenarios related to the lifecycle of workloads in - Application uses JAVA_OPTS environment variable in the k8s deployment manifest. - This workload verifies that after instrumentation is applied, those 2 options still works as expected. +## Python Workloads + +### python-latest-version +- Runs on the latest Python version. +- The instrumentation device should be added, and the agent must load and report traces as expected. +- We ensure that odigos-opentelemetry-python does not conflict with or overwrite the application's dependencies. + +### python-alpine +- Runs Python 3.10 on the Alpine Linux distribution. +- The instrumentation device should be added, and the agent must load and report traces as expected. +- We ensure that odigos-opentelemetry-python does not conflict with or overwrite the application's dependencies. +- The application utilizes the `PYTHONPATH` environment variable in the Kubernetes deployment manifest. + +### python-minimum-supported-version +- Runs Python 3.8 (minimum supported version). +- The instrumentation device should be added, and the agent must load and report traces as expected. +- We ensure that odigos-opentelemetry-python does not conflict with or overwrite the application's dependencies. + +### python-not-supported-version +- Runs Python 3.6. +- This version is not supported for instrumentation. +- We ensure that odigos-opentelemetry-python does not conflict with or overwrite the application's dependencies. ## CPP Workloads diff --git a/tests/e2e/workload-lifecycle/chainsaw-test.yaml b/tests/e2e/workload-lifecycle/chainsaw-test.yaml index e2f229572..027df8308 100644 --- a/tests/e2e/workload-lifecycle/chainsaw-test.yaml +++ b/tests/e2e/workload-lifecycle/chainsaw-test.yaml @@ -9,7 +9,7 @@ spec: - name: Build and Load Test App Images try: - script: - timeout: 200s + timeout: 350s content: | docker build -t nodejs-unsupported-version:v0.0.1 -f services/nodejs-http-server/unsupported-version.Dockerfile services/nodejs-http-server kind load docker-image nodejs-unsupported-version:v0.0.1 @@ -23,8 +23,10 @@ spec: kind load docker-image nodejs-dockerfile-env:v0.0.1 docker build -t nodejs-manifest-env:v0.0.1 -f services/nodejs-http-server/manifest-env.Dockerfile services/nodejs-http-server kind load docker-image nodejs-manifest-env:v0.0.1 + docker build -t cpp-http-server:v0.0.1 -f services/cpp-http-server/Dockerfile services/cpp-http-server kind load docker-image cpp-http-server:v0.0.1 + docker build -t java-supported-version:v0.0.1 -f services/java-http-server/java-supported-version.Dockerfile services/java-http-server kind load docker-image java-supported-version:v0.0.1 docker build -t java-azul:v0.0.1 -f services/java-http-server/java-azul.Dockerfile services/java-http-server @@ -38,6 +40,15 @@ spec: docker build -t java-old-version:v0.0.1 -f services/java-http-server/java-old-version.Dockerfile services/java-http-server kind load docker-image java-old-version:v0.0.1 + docker build -t python-latest-version:v0.0.1 -f services/python-http-server/Dockerfile.python-latest services/python-http-server + kind load docker-image python-latest-version:v0.0.1 + docker build -t python-alpine:v0.0.1 -f services/python-http-server/Dockerfile.python-alpine services/python-http-server + kind load docker-image python-alpine:v0.0.1 + docker build -t python-not-supported:v0.0.1 -f services/python-http-server/Dockerfile.python-not-supported-version services/python-http-server + kind load docker-image python-not-supported:v0.0.1 + docker build -t python-min-version:v0.0.1 -f services/python-http-server/Dockerfile.python-min-version services/python-http-server + kind load docker-image python-min-version:v0.0.1 + - name: Prepare destination try: - script: @@ -69,12 +80,19 @@ spec: try: - apply: file: 01-install-test-apps.yaml + + - name: '01 - Assert Apps installed' + try: - assert: file: 01-assert-apps-installed.yaml + - name: '01 Instrument Namespaces' - try: + try: - apply: file: 01-instrument-ns.yaml + + - name: '01 Assert runtime detection' + try: - assert: file: 01-assert-runtime-detected.yaml @@ -82,15 +100,18 @@ spec: try: - apply: file: 01-add-destination.yaml - - name: Assert Pipeline + + - name: "01 Assert Pipeline" try: - - assert: - file: 01-assert-pipeline.yaml - - name: Assert instrumented + - assert: + file: 01-assert-pipeline.yaml + + - name: "01 Assert Instrumented" try: - assert: file: 01-assert-instrumented.yaml - - name: Assert workload + + - name: "01 Assert Workloads" try: - assert: file: 01-assert-workloads.yaml @@ -100,7 +121,7 @@ spec: # send traffic to all services to verify they are working as expected and verify traces for instrumented ones try: - script: - timeout: 60s + timeout: 200s content: | set -e @@ -163,7 +184,7 @@ spec: - name: '02 - Generate Traffic' try: - script: - timeout: 60s + timeout: 200s content: | set -e @@ -191,8 +212,9 @@ spec: - script: timeout: 60s content: | - sleep 20 + sleep 20 + while true; do ../../common/traceql_runner.sh 02-wait-for-trace.yaml if [ $? -eq 0 ]; then diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-alpine b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-alpine new file mode 100644 index 000000000..45d9ec9f1 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-alpine @@ -0,0 +1,21 @@ +# Dockerfile.alpine +FROM python:3.10-alpine +WORKDIR /app +COPY . /app +RUN pip install --no-cache-dir django + +# Install dependencies from requirements.txt +COPY requirements.txt /app/requirements.txt + +RUN pip install --no-cache-dir -r requirements.txt + +# Supress health check endpoint from tracing +ENV OTEL_PYTHON_DJANGO_EXCLUDED_URLS=health/ + +RUN apk add sqlite + +COPY entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-latest b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-latest new file mode 100644 index 000000000..97fe5125f --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-latest @@ -0,0 +1,20 @@ +FROM python + +WORKDIR /app +COPY . /app + +# Install dependencies from requirements.txt +COPY requirements.txt /app/requirements.txt + +RUN apt-get update && apt-get install sqlite3 -y + +RUN pip install --no-cache-dir -r requirements.txt + +# Supress health check endpoint from tracing +ENV OTEL_PYTHON_DJANGO_EXCLUDED_URLS=health/ + +COPY entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-min-version b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-min-version new file mode 100644 index 000000000..f26428354 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-min-version @@ -0,0 +1,21 @@ +# Dockerfile.python38-slim +FROM python:3.8.0-slim +WORKDIR /app +COPY . /app + +# Install dependencies from requirements.txt +COPY requirements.txt /app/requirements.txt +# Install build tools for compiling packages with C extensions +RUN apt-get update && apt-get install -y \ + build-essential \ + gcc \ + sqlite3 + +RUN pip install --no-cache-dir -r requirements.txt + +# Supress health check endpoint from tracing +ENV OTEL_PYTHON_DJANGO_EXCLUDED_URLS=health/ + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-not-supported-version b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-not-supported-version new file mode 100644 index 000000000..3698c02a8 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/Dockerfile.python-not-supported-version @@ -0,0 +1,17 @@ +# Dockerfile.python37-slim +FROM python:3.6-slim +WORKDIR /app +COPY . /app + +RUN apt-get update && apt-get install sqlite3 -y + +RUN pip install --no-cache-dir -r requirements-legacy.txt + +# Supress health check endpoint from tracing +ENV OTEL_PYTHON_DJANGO_EXCLUDED_URLS=health/ + +COPY entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/db.sqlite3 b/tests/e2e/workload-lifecycle/services/python-http-server/db.sqlite3 new file mode 100644 index 000000000..e41a46532 Binary files /dev/null and b/tests/e2e/workload-lifecycle/services/python-http-server/db.sqlite3 differ diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/entrypoint.sh b/tests/e2e/workload-lifecycle/services/python-http-server/entrypoint.sh new file mode 100755 index 000000000..f6f72d4f9 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# entrypoint.sh + +set -e +# Run Django database migrations +echo "Running migrations..." +python manage.py migrate + +# Start the Django development server +echo "Starting server..." +exec python manage.py runserver 0.0.0.0:8000 --noreload diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/manage.py b/tests/e2e/workload-lifecycle/services/python-http-server/manage.py new file mode 100755 index 000000000..a53204a11 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp/__init__.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp/asgi.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/asgi.py new file mode 100644 index 000000000..5221a56c7 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for myapp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') + +application = get_asgi_application() diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp/settings.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/settings.py new file mode 100644 index 000000000..0176f41f4 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/settings.py @@ -0,0 +1,164 @@ +""" +Django settings for myapp project. + +Generated by 'django-admin startproject' using Django 5.1.1. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-6ua&dv4pj*%-vfxn1r62&n^_!&$x&ovn_xtom!h@_54j8os56+' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +APPEND_SLASH = False + + +ALLOWED_HOSTS = ['*'] + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.admin', + 'myapp_core', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'health_check' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'myapp.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'myapp.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + + +# settings.py (default for SQLite) +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# settings.py +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'opentelemetry.instrumentation.django': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + 'django': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, +} + diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp/urls.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/urls.py new file mode 100644 index 000000000..8b02b4c7c --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/urls.py @@ -0,0 +1,11 @@ +# myapp/urls.py +from django.contrib import admin +from django.urls import path, include +from myapp_core import views + +urlpatterns = [ + path('admin/', admin.site.urls), + path('insert-random/', views.insert_random_row, name='insert-random'), + path('fetch-all/', views.fetch_all_rows, name='fetch-all'), + path('health/', include('health_check.urls')), +] diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp/views.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/views.py new file mode 100644 index 000000000..a023127a7 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/views.py @@ -0,0 +1,4 @@ +from django.http import HttpResponse + +def index(request): + return HttpResponse("Hello, this is a test app!") diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp/wsgi.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/wsgi.py new file mode 100644 index 000000000..4b4a883b1 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for myapp project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') + +application = get_wsgi_application() diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/__init__.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/admin.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/apps.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/apps.py new file mode 100644 index 000000000..2a92c0b52 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MyappCoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'myapp_core' diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/migrations/0001_initial.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/migrations/0001_initial.py new file mode 100644 index 000000000..a7b7e05c1 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.1 on 2024-09-24 13:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ExampleModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/migrations/__init__.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/models.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/models.py new file mode 100644 index 000000000..938b059d2 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/models.py @@ -0,0 +1,9 @@ +# myapp_core/models.py +from django.db import models + +class ExampleModel(models.Model): + name = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/__init__.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example.proto b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example.proto new file mode 100644 index 000000000..2bd123fda --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package myapp_core; + +message ExampleMessage { + string name = 1; + int32 id = 2; + string created_at = 3; +} \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example_pb2.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example_pb2.py new file mode 100644 index 000000000..c7c469506 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example_pb2.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: example.proto +# Protobuf Python Version: 5.28.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 5, + 28, + 1, + '', + 'example.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rexample.proto\x12\nmyapp_core\">\n\x0e\x45xampleMessage\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\x12\n\ncreated_at\x18\x03 \x01(\tb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'example_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_EXAMPLEMESSAGE']._serialized_start=29 + _globals['_EXAMPLEMESSAGE']._serialized_end=91 +# @@protoc_insertion_point(module_scope) diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example_pb2_legacy.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example_pb2_legacy.py new file mode 100644 index 000000000..7c16727b9 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/proto/example_pb2_legacy.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: proto/example.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13proto/example.proto\x12\nmyapp_core\">\n\x0e\x45xampleMessage\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\x12\n\ncreated_at\x18\x03 \x01(\tb\x06proto3') + + + +_EXAMPLEMESSAGE = DESCRIPTOR.message_types_by_name['ExampleMessage'] +ExampleMessage = _reflection.GeneratedProtocolMessageType('ExampleMessage', (_message.Message,), { + 'DESCRIPTOR' : _EXAMPLEMESSAGE, + '__module__' : 'proto.example_pb2' + # @@protoc_insertion_point(class_scope:myapp_core.ExampleMessage) + }) +_sym_db.RegisterMessage(ExampleMessage) + +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _EXAMPLEMESSAGE._serialized_start=35 + _EXAMPLEMESSAGE._serialized_end=97 +# @@protoc_insertion_point(module_scope) diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/views.py b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/views.py new file mode 100644 index 000000000..38babd542 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/myapp_core/views.py @@ -0,0 +1,101 @@ +# myapp_core/views.py +import random +from django.http import HttpResponse +from .models import ExampleModel +import logging +from packaging.version import parse, Version +import importlib_metadata +import asyncio +from asgiref.sync import async_to_sync + +import sys + + +# Import the protobuf message based on the Python version +# In our min version test (3.6) protobuf 3.19.6 is used +if sys.version_info < (3, 8): + from .proto import example_pb2_legacy as example_pb2 +else: + from .proto import example_pb2 + import google.protobuf + # Assert python protobuf version is the same as pinned in the requirements.txt + if google.protobuf.__version__ != '5.28.2': + raise ImportError(f"Expected protobuf version 5.28.2, got {google.protobuf.__version__}") + +logger = logging.getLogger() + + +async def async_greeting(): + await asyncio.sleep(1) + return "Hello from async!" + + +def insert_random_row(request): + try: + # Uses some 3rd packages that conflict with odigos-opentelemetry-python + # These few lines wont have business logic, but just to try to reproduce the conflicts + + ### importlib_metadata + package_name = "Django" + metadata = importlib_metadata.metadata(package_name) + django_version = metadata['Version'] + logger.info(f"Using Django version: {django_version}") + + ### packaging + v1 = parse("3.1.4") + v2 = parse(django_version) + if v1 < v2: + version_comparison_result = f"v1 ({v1}) is older than Django version ({v2})" + else: + version_comparison_result = f"v1 ({v1}) is newer than Django version ({v2})" + logger.info(version_comparison_result) + + ### asgiref + greeting = async_to_sync(async_greeting)() + logger.info(f"Async greeting: {greeting}") + + + # Create a new entry in the database + new_entry = ExampleModel.objects.create(name=f"RandomName{random.randint(1, 1000)}") + + # google.protobuf + message = example_pb2.ExampleMessage( + name=new_entry.name, + id=new_entry.id, + created_at=str(new_entry.created_at) + ) + serialized_message = message.SerializeToString() + logger.info(f"Serialized protobuf message: {serialized_message}") + + return HttpResponse(f"Inserted random row and serialized protobuf: {new_entry.name}") + except Exception as e: + logger.error(f"Error inserting random row: {e}") + return HttpResponse(f"Error inserting random row: {e}", status=500) + + +# Create a view to fetch all rows +def fetch_all_rows(request): + try: + all_entries = ExampleModel.objects.all() + + # Deserialize protobuf data from each entry + entries_list = [] + for entry in all_entries: + message = example_pb2.ExampleMessage( + name=entry.name, + id=entry.id, + created_at=str(entry.created_at) + ) + serialized_message = message.SerializeToString() + + # Deserialize the message back to the object + deserialized_message = example_pb2.ExampleMessage() + deserialized_message.ParseFromString(serialized_message) + + entries_list.append(f"{deserialized_message.name} (ID: {deserialized_message.id})") + + logger.info(f"Fetched and deserialized rows: {entries_list}") + return HttpResponse(f"All rows: {', '.join(entries_list)}") + except Exception as e: + logger.error(f"Error fetching rows: {e}") + return HttpResponse(f"Error fetching rows: {e}", status=500) \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/requirements-legacy.txt b/tests/e2e/workload-lifecycle/services/python-http-server/requirements-legacy.txt new file mode 100644 index 000000000..a6c874b02 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/requirements-legacy.txt @@ -0,0 +1,5 @@ +django==3.2.25 +protobuf==3.19.6 +packaging +importlib_metadata +django-health-check \ No newline at end of file diff --git a/tests/e2e/workload-lifecycle/services/python-http-server/requirements.txt b/tests/e2e/workload-lifecycle/services/python-http-server/requirements.txt new file mode 100644 index 000000000..549c25564 --- /dev/null +++ b/tests/e2e/workload-lifecycle/services/python-http-server/requirements.txt @@ -0,0 +1,7 @@ +Django +protobuf==5.28.2 +importlib_metadata +packaging +asgiref +asyncio +django-health-check \ No newline at end of file