From 5d4a78efb9dd8dae3df7e0c155c97c091a20b3e8 Mon Sep 17 00:00:00 2001 From: GlassOfWhiskey Date: Sun, 9 Feb 2025 18:44:51 +0100 Subject: [PATCH] Improve StreamFlow on Kubernetes This commit heavily refactors the StreamFlow Helm chart to simplify its deployment on top of Kubernetes clusters. In addition, this commit adds a `networkPolicy` flag to control the behaviour of CWL `DockerRequirement` objects into Kubernetes `Pod` items. Normally, the CWL `NetworkAccess` requirement is enforced through Kubernetes `NetworkPolicy` objects. However, `NetworkPolicy` objects regulate the network security inside a cluster, and giving the StreamFlow `Pod` permissions to create/delete them may result in unwanted security flaws. The `networkPolicy` option can be set to `False` to ignore the CWL `NetworkAccess` enforcement in such cases. --- .github/workflows/ci-tests.yaml | 2 +- helm/chart/Chart.yaml | 4 +- helm/chart/templates/_helpers.tpl | 43 +++- helm/chart/templates/configmap.yaml | 36 ++++ helm/chart/templates/job.yaml | 63 ++++-- helm/chart/templates/role.yaml | 34 +++ helm/chart/templates/rolebinding.yaml | 17 ++ helm/chart/templates/serviceaccount.yaml | 1 + helm/chart/values.yaml | 193 ++++++++++++++++-- .../cwl/requirement/docker/kubernetes.py | 3 + .../docker/schemas/kubernetes.jinja2 | 2 +- .../docker/schemas/kubernetes.json | 5 + streamflow/deployment/connector/kubernetes.py | 34 +-- .../cwl-conformance/streamflow-kubernetes.yml | 3 +- tests/test_schema.py | 4 +- 15 files changed, 393 insertions(+), 51 deletions(-) create mode 100644 helm/chart/templates/configmap.yaml create mode 100644 helm/chart/templates/role.yaml create mode 100644 helm/chart/templates/rolebinding.yaml diff --git a/.github/workflows/ci-tests.yaml b/.github/workflows/ci-tests.yaml index b8a7e2d23..26c0ea363 100644 --- a/.github/workflows/ci-tests.yaml +++ b/.github/workflows/ci-tests.yaml @@ -157,7 +157,7 @@ jobs: python -m pip install -r docs/requirements.txt - name: "Build documentation and check for consistency" env: - CHECKSUM: "221232eb10bca5a16895e6ed4bb8fc959044a804bfc045a4751f2f17238b9c93" + CHECKSUM: "1f7be9cd7f0f23aae63f6657e22a8b008b73ccca786af77dcf7975cc2422fdc0" run: | cd docs HASH="$(make checksum | tail -n1)" diff --git a/helm/chart/Chart.yaml b/helm/chart/Chart.yaml index 688e25e5e..afd17c69c 100644 --- a/helm/chart/Chart.yaml +++ b/helm/chart/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: streamflow -description: A Helm chart for StreamFlow +description: A Helm chart for the StreamFlow workflow management system type: application version: 0.2.0 -appVersion: latest +appVersion: 0.2.0.dev11 diff --git a/helm/chart/templates/_helpers.tpl b/helm/chart/templates/_helpers.tpl index 6005aa0ea..12b69e736 100644 --- a/helm/chart/templates/_helpers.tpl +++ b/helm/chart/templates/_helpers.tpl @@ -1,6 +1,6 @@ {{/* vim: set filetype=mustache: */}} {{/* -Expand the name of the chart. +Expand the name of the chart */}} {{- define "streamflow.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} @@ -9,7 +9,7 @@ Expand the name of the chart. {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. +If release name contains chart name it will be used as a full name */}} {{- define "streamflow.fullname" -}} {{- if .Values.fullnameOverride -}} @@ -25,12 +25,49 @@ If release name contains chart name it will be used as a full name. {{- end -}} {{/* -Create chart name and version as used by the chart label. +Create chart name and version as used by the chart label */}} {{- define "streamflow.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{/* +Return the proper StreamFlow image name +*/}} +{{- define "streamflow.image" -}} +{{- $registryName := default .Values.image.registry -}} +{{- $repositoryName := .Values.image.repository -}} +{{- $separator := ":" -}} +{{- $termination := default .Chart.AppVersion .Values.image.tag | toString -}} + +{{- if not .Values.image.tag }} + {{- if .Chart }} + {{- $termination = .Chart.AppVersion | toString -}} + {{- end -}} +{{- end -}} +{{- if .Values.image.digest }} + {{- $separator = "@" -}} + {{- $termination = .Values.image.digest | toString -}} +{{- end -}} +{{- if $registryName }} + {{- printf "%s/%s%s%s" $registryName $repositoryName $separator $termination -}} +{{- else -}} + {{- printf "%s%s%s" $repositoryName $separator $termination -}} +{{- end -}} +{{- end -}} + +{{/* +Return the proper Docker Image Registry Secret Names evaluating values as templates +*/}} +{{- define "streamflow.imagePullSecrets" -}} +{{- if (not (empty .Values.image.pullSecrets)) -}} +imagePullSecrets: + {{- range .Values.image.pullSecrets | uniq }} + - name: {{ . }} + {{- end }} +{{- end }} +{{- end }} + {{/* Common labels */}} diff --git a/helm/chart/templates/configmap.yaml b/helm/chart/templates/configmap.yaml new file mode 100644 index 000000000..7aeed01d9 --- /dev/null +++ b/helm/chart/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "streamflow.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "streamflow.labels" . | nindent 4 }} +data: + streamflow.yml: |- + version: v1.0 + workflows: + {{ .Values.streamflow.workflow.name | default uuidv4 }}: + type: {{ .Values.streamflow.workflow.type }} + {{- if .Values.streamflow.workflow.bindings }} + {{- with .Values.streamflow.workflow.bindings }} + bindings: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + config: + {{- if eq .Values.streamflow.workflow.type "cwl" }} + file: {{ required "CWL processfile is mandatory" .Values.streamflow.workflow.cwl.processfile }} + {{- if .Values.streamflow.workflow.cwl.jobfile }} + settings: {{ .Values.streamflow.workflow.cwl.jobfile }} + {{- end }} + docker: + - step: / + deployment: + type: kubernetes + config: + inCluster: true + networkPolicy: {{ .Values.streamflow.workflow.cwl.restrictNetworkAccess }} + {{- end }} + {{- if .Values.streamflow.config }} + {{- toYaml .Values.streamflow.config | nindent 4 }} + {{- end }} \ No newline at end of file diff --git a/helm/chart/templates/job.yaml b/helm/chart/templates/job.yaml index accbb5dab..ae79665ad 100644 --- a/helm/chart/templates/job.yaml +++ b/helm/chart/templates/job.yaml @@ -13,32 +13,67 @@ spec: labels: {{- include "streamflow.selectorLabels" . | nindent 8 }} spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} serviceAccountName: {{ include "streamflow.serviceAccountName" . }} + {{- include "streamflow.imagePullSecrets" . | nindent 6 }} + {{- if .Values.podSecurityContext.enabled }} securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- omit .Values.podSecurityContext "enabled" | toYaml | nindent 8 }} + {{- end }} containers: - - name: {{ .Chart.Name }} + - name: {{ include "streamflow.fullname" . }} + {{- if .Values.containerSecurityContext.enabled }} securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + {{- omit .Values.containerSecurityContext "enabled" | toYaml | nindent 12 }} + {{- end }} + image: {{ include "streamflow.image" . }} + {{- if .Values.command }} + command: {{ .Values.command }} + {{- end }} + {{- if .Values.args }} args: {{ .Values.args }} + {{- end }} imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.resources }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- end }} + volumeMounts: + - name: streamflow-config + mountPath: /streamflow/results/streamflow.yml + subPath: streamflow.yml + - name: streamflow-metadata + mountPath: /.streamflow + - name: streamflow-outdir + mountPath: /tmp/streamflow + - name: streamflow-workdir + mountPath: /streamflow/results + {{ if .Values.restartPolicy }} restartPolicy: {{ .Values.restartPolicy }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} + {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} + volumes: + - name: streamflow-metadata + {{- if .Values.persistence.metadata }} + {{ toYaml .Values.persistence.metadata | nindent 10}} + {{- else }} + emptyDir: {} + {{- end }} + - name: streamflow-outdir + {{- if .Values.persistence.outdir }} + {{ toYaml .Values.persistence.outdir | nindent 10}} + {{- else }} + emptyDir: {} + {{- end }} + - name: streamflow-workdir + {{- if .Values.persistence.workdir }} + {{ toYaml .Values.persistence.workdir | nindent 10}} + {{- else }} + emptyDir: {} + {{- end }} diff --git a/helm/chart/templates/role.yaml b/helm/chart/templates/role.yaml new file mode 100644 index 000000000..4d5401730 --- /dev/null +++ b/helm/chart/templates/role.yaml @@ -0,0 +1,34 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "streamflow.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "streamflow.labels" . | nindent 4 }} +rules: +- verbs: + - get + - watch + - list + - create + - delete + apiGroups: + - '' + resources: + - pods + - pods/exec +{{- if eq .Values.streamflow.workflow.type "cwl" }} +{{- if .Values.streamflow.workflow.restrictNetworkAccess }} +- verbs: + - get + - list + - create + - delete + apiGroups: + - networking.k8s.io + resources: + - networkpolicies +{{- end }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/chart/templates/rolebinding.yaml b/helm/chart/templates/rolebinding.yaml new file mode 100644 index 000000000..bf0465af3 --- /dev/null +++ b/helm/chart/templates/rolebinding.yaml @@ -0,0 +1,17 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "streamflow.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "streamflow.labels" . | nindent 4 }} +roleRef: + kind: Role + name: {{ include "streamflow.fullname" . }} + apiGroup: rbac.authorization.k8s.io +subjects: + - kind: ServiceAccount + name: {{ include "streamflow.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/helm/chart/templates/serviceaccount.yaml b/helm/chart/templates/serviceaccount.yaml index 9c3c6540f..e1f6b2756 100644 --- a/helm/chart/templates/serviceaccount.yaml +++ b/helm/chart/templates/serviceaccount.yaml @@ -9,4 +9,5 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} {{- end -}} diff --git a/helm/chart/values.yaml b/helm/chart/values.yaml index 3a8930e92..c6c3fa905 100644 --- a/helm/chart/values.yaml +++ b/helm/chart/values.yaml @@ -1,28 +1,197 @@ -replicaCount: 1 +## String to partially override streamflow.fullname template (will maintain the release name) +## +nameOverride: "" + +## String to fully override streamflow.fullname template +## +fullnameOverride: "" +## @section StreamFlow image version +## ref: https://hub.docker.com/r/alphaunito/streamflow/tags/ +## image: + ## @param image.registry StreamFlow image registry + ## + registry: docker.io + ## @param image.repository StreamFlow image repository + ## repository: alphaunito/streamflow - pullPolicy: Always + ## @skip image.tag StreamFlow image tag (immutable tags are recommended) + ## + tag: "" + ## @param image.digest StreamFlow image digest in the way sha256:aa.... Please note this parameter, if set, will override the tag + ## + digest: "" + ## @param image.pullPolicy StreamFLow image pull + ## ref: https://kubernetes.io/docs/concepts/containers/images/#pre-pulled-images + ## + pullPolicy: IfNotPresent + ## @param image.pullSecrets Specify image pull secrets + ## Secrets must be manually created in the namespace. + ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + ## + pullSecrets: [] -args: ["streamflow", "version"] -restartPolicy: OnFailure -imagePullSecrets: [] -nameOverride: "" -fullnameOverride: "" +## Override default container command +## +command: [] + +## Override default container args +## +args: [] +## @section Container Security Context +## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ +## +containerSecurityContext: + ## @param containerSecurityContext.enabled Enable security context + ## + enabled: true + ## @param containerSecurityContext.seLinuxOptions Set SELinux options in StreamFlow container + ## + seLinuxOptions: {} + ## @param containerSecurityContext.runAsUser Set user in StreamFlow container + ## + runAsUser: 1001 + ## @param containerSecurityContext.runAsGroup Set group in StreamFlow container + ## + runAsGroup: 1001 + ## @param containerSecurityContext.runAsNonRoot Require StreamFlow container to run as non-root user + ## + runAsNonRoot: true + ## @param containerSecurityContext.privileged Runs the StreamFlow container as privileged + ## + privileged: false + ## @param containerSecurityContext.readOnlyRootFilesystem Mounts the StreamFlow container's root filesystem as read-only + ## + readOnlyRootFilesystem: true + ## @param containerSecurityContext.allowPrivilegeEscalation controls whether a process can gain more privileges than its parent process in the StreamFlow container + ## + allowPrivilegeEscalation: false + ## @param containerSecurityContext.capabilities Controls processes' privileges in the StreamFlow container + ## + capabilities: + drop: ["ALL"] + ## @param containerSecurityContext.seccompProfile Filter processes' system calls in the StreamFlow container + ## + seccompProfile: + type: "RuntimeDefault" + +## @section Pod Security Context +## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ +## +podSecurityContext: + ## @param podSecurityContext.enabled Enable security context + ## + enabled: true + ## @param podSecurityContext.fsGroupChangePolicy Set filesystem group change policy + ## + fsGroupChangePolicy: Always + ## @param podSecurityContext.sysctls Set kernel settings using the sysctl interface + ## + sysctls: [] + ## @param podSecurityContext.supplementalGroups Set filesystem extra groups + ## + supplementalGroups: [] + ## @param podSecurityContext.fsGroup Group ID for the StreamFlow pod + ## + fsGroup: 1001 + +## @section Service account for StreamFlow to use. +## ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/ +## serviceAccount: + ## @param serviceAccount.create Enable creation of ServiceAccount for StreamFlow pod + ## create: true + ## @param serviceAccount.name The name of the ServiceAccount to use. + ## If not set and create is true, a name is generated using the streamflow.fullname template + ## + name: "" + ## @param serviceAccount.automountServiceAccountToken Allows auto mount of ServiceAccountToken on the serviceAccount created + ## Can be set to false if pods using this serviceAccount do not need to use K8s API + ## + automountServiceAccountToken: true + ## @param serviceAccount.annotations Additional custom annotations for the ServiceAccount + ## annotations: {} - name: -podSecurityContext: {} +## @section Creates role for ServiceAccount +## ref: https://kubernetes.io/docs/reference/access-authn-authz/rbac/#service-account-permissions +## +rbac: + ## @param rbac.create Create Role and RoleBinding (required for StreamFlow to instantiate other Pods in the cluster) + ## + create: true + ## @param rbac.rules Custom RBAC rules to set + ## + rules: [] + +## @section Set up the StreamFlow comnfiguration file +## ref: https://streamflow.di.unito.it/documentation/latest/ +## +streamflow: + ## @section streamflow.workflow Specify which workflow should be executed + ## + workflow: + ## @param streamflow.workflow.name The name of the workflow (if not specified, a random name will be used) + ## + name: "" + ## @param streamflow.workflow.type The type of the workflow (defaults to cwl) + ## + type: "cwl" + ## @param streamflow.workflow.bindings Binds each workflow step to a target execution environment + ## + bindings: [] + ## @section streamflow.workflow.cwl Configures the execution of a CWL workflow + ## + cwl: + ## @param streamflow.workflow.cwl.processfile The target CWL file to execute + ## + processfile: "example.cwl" + ## @param streamflow.workflow.cwl.jobfile A file describing the inputs of the CWL workflow + ## + jobfile: "" + ## @param streamflow.workflow.cwl.restrictNetworkAccess Use NetworkPolicy objects to restrict containers' network access according to the CWL NetworkAccess requirement + ## + restrictNetworkAccess: false + ## @param streamflow.workflow.config The workflow configuration + ## + config: {} + ## @param streamflow.configExtra Specify additional StreamFlow properties + ## + configExtra: {} + +## @section Configure persistent volumes for the StreamFlow Pod +## Note that PersistentVolumeClaim objects are not managed by this Chart and should be manually created by the user +## ref: https://kubernetes.io/docs/concepts/storage/volumes/ +## +persistence: + ## @param Configure the $HOME/.streamflow volume to store StreamFlow metadata + ## + metadata: {} + ## @param Configure the /streamflow/results volume to store StreamFlow input and output data + ## + outdir: {} + ## @param Configure the /tmp/streamflow volume to store workflows' intermediate data + ## + workdir: {} -securityContext: {} +## Configure the Restart Policy for the StreamFlow container +## ref: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#restart-policy +## +restartPolicy: "" +## Set requests and limits for different resources (e.g., CPU or memory) for the StreamFlow container +## resources: {} +## Node labels for StreamFlow pod assignment +## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ +## nodeSelector: {} +## Tolerations for StreamFlow pod assignment +## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ +## tolerations: [] - -affinity: {} diff --git a/streamflow/cwl/requirement/docker/kubernetes.py b/streamflow/cwl/requirement/docker/kubernetes.py index 8ddc03e89..7034a0866 100644 --- a/streamflow/cwl/requirement/docker/kubernetes.py +++ b/streamflow/cwl/requirement/docker/kubernetes.py @@ -22,6 +22,7 @@ def __init__( kubeContext: str | None = None, maxConcurrentConnections: int = 4096, namespace: str | None = None, + networkPolicy: bool = False, locationsCacheSize: int | None = None, locationsCacheTTL: int | None = None, transferBufferSize: int = (2**25) - 1, @@ -45,6 +46,7 @@ def __init__( self.kubeContext: str | None = kubeContext self.maxConcurrentConnections: int = maxConcurrentConnections self.namespace: str | None = namespace + self.networkPolicy: bool = networkPolicy self.locationsCacheSize: int | None = locationsCacheSize self.locationsCacheTTL: int | None = locationsCacheTTL self.transferBufferSize: int = transferBufferSize @@ -73,6 +75,7 @@ def get_target( name=name, image=image, network_access=network_access, + network_policy=self.networkPolicy, output_directory=output_directory, ).dump(f.name) return Target( diff --git a/streamflow/cwl/requirement/docker/schemas/kubernetes.jinja2 b/streamflow/cwl/requirement/docker/schemas/kubernetes.jinja2 index 0ea4c68b0..86a14b8f8 100644 --- a/streamflow/cwl/requirement/docker/schemas/kubernetes.jinja2 +++ b/streamflow/cwl/requirement/docker/schemas/kubernetes.jinja2 @@ -26,7 +26,7 @@ spec: - name: {{ name }}-workdir emptyDir: {} {% endif %} -{% if not network_access %} +{% if network_policy and not network_access %} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy diff --git a/streamflow/cwl/requirement/docker/schemas/kubernetes.json b/streamflow/cwl/requirement/docker/schemas/kubernetes.json index ba8f3e86c..3667e88a6 100644 --- a/streamflow/cwl/requirement/docker/schemas/kubernetes.json +++ b/streamflow/cwl/requirement/docker/schemas/kubernetes.json @@ -45,6 +45,11 @@ "description": "Available locations cache TTL (in seconds). When such cache expires, the connector performs a new request to check locations availability", "default": 10 }, + "networkPolicy": { + "type": "boolean", + "description": "Use a NetworkPolicy object to explicitly limit containers' network access when the NetworkAccess requirement is set to false", + "default": false + }, "timeout": { "type": "integer", "description": "Time (in seconds) to wait for any individual Kubernetes operation", diff --git a/streamflow/deployment/connector/kubernetes.py b/streamflow/deployment/connector/kubernetes.py index 92aed2d60..f0beb53b2 100644 --- a/streamflow/deployment/connector/kubernetes.py +++ b/streamflow/deployment/connector/kubernetes.py @@ -448,11 +448,6 @@ async def run( job=f"for job {job_name}" if job_name else "", ) ) - command = ( - ["sh", "-c"] - + [f"{k}={v}" for k, v in location.environment.items()] - + [utils.encode_command(command)] - ) pod, container = location.name.split(":") # noinspection PyUnresolvedReferences result = await asyncio.wait_for( @@ -462,7 +457,11 @@ async def run( name=pod, namespace=self.namespace or "default", container=container, - command=command, + command=( + ["sh", "-c"] + + [f"{k}={v}" for k, v in location.environment.items()] + + [utils.encode_command(command)] + ), stderr=True, stdin=False, stdout=True, @@ -487,16 +486,21 @@ async def run( out_buffer.write(data) elif data and channel == ws_client.ERROR_CHANNEL: err_buffer.write(data) - err = yaml.safe_load(err_buffer.getvalue()) - if err["status"] == "Success": - return out_buffer.getvalue(), 0 - else: - if "code" in err: - return err["message"], int(err["code"]) + if err := yaml.safe_load(err_buffer.getvalue()): + if err["status"] == "Success": + return out_buffer.getvalue(), 0 else: - return err["message"], int( - err["details"]["causes"][0]["message"] - ) + if "code" in err: + return err["message"], int(err["code"]) + else: + return err["message"], int( + err["details"]["causes"][0]["message"] + ) + else: + raise WorkflowExecutionException( + f"Connection to pod {pod} in namespace {self.namespace or 'default'} closed unexpectedly " + f"while executing command {command}" + ) else: return None diff --git a/tests/cwl-conformance/streamflow-kubernetes.yml b/tests/cwl-conformance/streamflow-kubernetes.yml index 2822da75e..5d6f9334b 100644 --- a/tests/cwl-conformance/streamflow-kubernetes.yml +++ b/tests/cwl-conformance/streamflow-kubernetes.yml @@ -6,7 +6,8 @@ workflows: - step: / deployment: type: kubernetes - config: {} + config: + networkPolicy: true database: type: default config: diff --git a/tests/test_schema.py b/tests/test_schema.py index a64d4dca2..3e22868d3 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -181,11 +181,11 @@ def test_schema_generation(): """Check that the `streamflow schema` command generates a correct JSON Schema.""" assert ( hashlib.sha256(SfSchema().dump("v1.0", False).encode()).hexdigest() - == "bc4b2e67f592fdcd164510df743b4d91da723051ed9523bd1c4e4b0da23e260a" + == "b6bce7ddab202ab8bcd2e4fe448c9d263cf54c2fad5b2a53a9abe5a83e2db274" ) assert ( hashlib.sha256(SfSchema().dump("v1.0", True).encode()).hexdigest() - == "6514e0a6c7f5e74017015ca2c517d1f90de0b56615e286b7949593ef3537cc17" + == "cd38611cbb96ef8adf5922e75994321b65a55b9e9fd99beaa203f26c8a125e7e" )