diff --git a/.github/workflows/ci-tests.yaml b/.github/workflows/ci-tests.yaml index 005850acf..aa6b94332 100644 --- a/.github/workflows/ci-tests.yaml +++ b/.github/workflows/ci-tests.yaml @@ -161,7 +161,7 @@ jobs: python -m pip install . - name: "Build documentation and check for consistency" env: - CHECKSUM: "47721124c7689e9138ec656aecb30367fe9571fc088748e8b802df4aba5ba465" + CHECKSUM: "3b6dab7d80523dce07afcea3a0a0a8b55ca1040398630240fe681fa58b44fc57" 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 16980ac1c..03288d913 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 44e4bb160..609c6b692 100644 --- a/streamflow/cwl/requirement/docker/schemas/kubernetes.json +++ b/streamflow/cwl/requirement/docker/schemas/kubernetes.json @@ -8,6 +8,64 @@ "type": "string", "description": "Path to a file containing a Jinja2 template, describing how the Docker container should be deployed on Kubernetes", "default": "./kubernetes.jinja2" + }, + "debug": { + "type": "boolean", + "description": "Enable verbose output" + }, + "inCluster": { + "type": "boolean", + "description": "If true, the Helm connector will use a ServiceAccount to connect to the Kubernetes cluster. This is useful when StreamFlow runs directly inside a Kubernetes Pod", + "default": false + }, + "kubeContext": { + "type": "string", + "description": "Name of the kubeconfig context to use" + }, + "kubeconfig": { + "type": "string", + "description": "Absolute path of the kubeconfig file to be used" + }, + "maxConcurrentConnections": { + "type": "integer", + "description": "Maximum number of concurrent connections to open for a single Kubernetes client", + "default": 4096 + }, + "namespace": { + "type": "string", + "description": "Namespace to install the release into", + "default": "Current kube config namespace" + }, + "locationsCacheSize": { + "type": "integer", + "description": "Available locations cache size", + "default": 10 + }, + "locationsCacheTTL": { + "type": "integer", + "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", + "default": "60000" + }, + "transferBufferSize": { + "type": "integer", + "description": "Buffer size allocated for local and remote data transfers", + "default": "32MiB - 1B", + "$comment": "Kubernetes Python client talks with its server counterpart, written in Golang, via Websocket protocol. The standard websocket package in Golang defines DefaultMaxPayloadBytes equal to 32 MB. Nevertheless, since kubernetes-client prepends channel number to the actual payload (which is always 0 for STDIN), we must reserve 1 byte for this purpose" + }, + "wait": { + "type": "boolean", + "description": "If set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment are in a ready state before marking the release as successful. It will wait for as long as timeout", + "default": true } }, "unevaluatedProperties": false diff --git a/streamflow/deployment/connector/kubernetes.py b/streamflow/deployment/connector/kubernetes.py index d2b170c7a..b3f8c1f78 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 4a86966b1..7bf84fee1 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() - == "de0e5736eaa46a70b4d9b28e2faa7b235b2d965886fa1b8bfe80428d131ee31b" + == "684cbec763d426b41858e8908b62543659b795d54866dbff69ee469d16484e25" ) assert ( hashlib.sha256(SfSchema().dump("v1.0", True).encode()).hexdigest() - == "5aef0ec1925e490075e126d0bc38ed987391cbf10b401bea3ca3a1f4ccb0c0fd" + == "acbd07a95ff30a5ceb3dee584bbe088ab740f2f77ade99fb8505c1f4be0f6137" )