0.9.1 (#113)

* fix: fix absolute path in run.sh

starting the operator locally gave error "No such file or directory task: Failed to run task "run": task: Failed to run task "d:run": exit status 127", because of an absolute path inside run.sh

* fix: path to check for db ready

changed the path for wait_db_ready. The path without the final / led to a 301 and not a 200

* fix: invoker and operator image from env

removed hardcoded values for cfg.get operator.image / operator.tag and invoker.image and invoker.tag. Config now check env vars and defaults to old images

* fix(test): milvus tests

fixed tests for minio / seaweedfs .

* chore(docs): taskfile

added desc to taskfile tasks

* fix: namespace validation

added namespace validation in user creation

* chore(deps): bump urllib3 from 2.3.0 to 2.7.0

---------

Co-authored-by: Michele Sciabarra <30654959+sciabarracom@users.noreply.github.com>
diff --git a/Taskfile.yml b/Taskfile.yml
index 16d412e..51341e5 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -74,10 +74,13 @@
 
 tasks:
 
-  default: 
+  default:
+    desc: alias for "use" (show/select the current kubernetes cluster)
+    cmds:
     - task: use
 
   setup:
+    desc: setup the local development environment (deps, ssh key, env config, poetry)
     deps:
       - update-files-from-openwhisk
       - ssh-key
@@ -88,55 +91,100 @@
     status:
       - test -e $GOBIN/kopf
       
-  env: env
-  
-  watch: watch kubectl -n {{.NS}} get nodes,pod,svc,pvc,ingress
-  watch-osh: watch kubectl -n {{.NS}} get nodes,pod,svc,pvc,route
-  watch-cert: watch kubectl -n {{.NS}} get ingress,ClusterIssuers,Certificates,CertificateRequests,Orders,Challenges 
-  watch-pod: watch kubectl -n {{.NS}} get po,job --no-headers
+  env:
+    desc: print the current environment variables
+    cmds:
+    - env
 
-  logs: >
-    rm -f nuvolaris-operator.log ;
-    kubectl -n nuvolaris logs pod/nuvolaris-operator -f | tee nuvolaris-operator.log
+  watch:
+    desc: watch nodes, pods, services, pvcs and ingresses in the namespace
+    cmds:
+    - watch kubectl -n {{.NS}} get nodes,pod,svc,pvc,ingress
 
-  cli: 
+  watch-osh:
+    desc: watch nodes, pods, services, pvcs and routes (OpenShift) in the namespace
+    cmds:
+    - watch kubectl -n {{.NS}} get nodes,pod,svc,pvc,route
+
+  watch-cert:
+    desc: watch cert-manager resources (ingress, issuers, certificates, orders, challenges)
+    cmds:
+    - watch kubectl -n {{.NS}} get ingress,ClusterIssuers,Certificates,CertificateRequests,Orders,Challenges
+
+  watch-pod:
+    desc: watch pods and jobs in the namespace
+    cmds:
+    - watch kubectl -n {{.NS}} get po,job --no-headers
+
+  logs:
+    desc: tail the operator logs to nuvolaris-operator.log
+    cmds:
+    - >
+      rm -f nuvolaris-operator.log ;
+      kubectl -n nuvolaris logs pod/nuvolaris-operator -f | tee nuvolaris-operator.log
+
+  cli:
+    desc: run the interactive development CLI
+    cmds:
     - task: d:cli
 
   run:
+    desc: prepare system actions, apply permissions and run the operator locally
+    cmds:
     - task: sys:prepare
     - task: t:permission
     - task: d:run
-  
+
   irun:
-  - task: instance
-  - task: d:run
+    desc: deploy an instance and run the operator locally
+    cmds:
+    - task: instance
+    - task: d:run
 
   permission:
+    desc: apply the nuvolaris permissions
+    cmds:
     - task: t:permission
 
   operator:
+    desc: deploy the operator
+    cmds:
     - task: t:operator
 
   instance:
+    desc: deploy an openwhisk instance
+    cmds:
     - task: t:instance
 
   instance-wfx:
+    desc: deploy an openwhisk instance with workflow extension
+    cmds:
     - task: d:instance-wfx
 
   instance-and-log:
+    desc: deploy an instance and follow the operator logs
+    cmds:
     - task: d:instance
     - kubectl -n nuvolaris logs pod/nuvolaris-operator -f
 
-  mongo: 
-    - task: t:mongo 
+  mongo:
+    desc: run the mongo integration test
+    cmds:
+    - task: t:mongo
 
-  minio: 
+  minio:
+    desc: run the minio integration test
+    cmds:
     - task: t:minio
 
   postgres:
-    - task: t:postgres      
+    desc: run the postgres integration test
+    cmds:
+    - task: t:postgres
 
   minimal:
+    desc: deploy operator and a minimal instance, then run hello test
+    cmds:
     #- task: permission
     - task: operator
     - task  instance WHISK=minimal
@@ -144,6 +192,8 @@
     - task: hello
 
   all:
+    desc: full end-to-end setup and test of all services
+    cmds:
     - task: permission
     - task: operator
     - task: instance
@@ -155,28 +205,41 @@
     - task: postgres
 
   config:
+    desc: configure ops wsk properties for the deployed instance
+    cmds:
     - task: t:config
 
-  hello: 
+  hello:
+    desc: run the hello world test action
+    cmds:
     - task: t:hello
 
-  redis: 
+  redis:
+    desc: run the redis integration test
+    cmds:
     - task: t:redis
 
-  workflow: 
+  workflow:
+    desc: apply the workflow test manifest
+    cmds:
     - kubectl -n nuvolaris apply -f tests/workflow-test.yaml
 
   defin:
+    desc: remove finalizers to force-delete the wsk controller resource
+    cmds:
     - task: d:defin
 
   # old., to be removed
   image-tag:
-    - git tag -d $(git tag) 
+    desc: "[deprecated] generate and force a new git tag based on the current time"
+    cmds:
+    - git tag -d $(git tag)
     - git tag -f {{.P}}{{.BASETAG}}.$(date +%y%m%d%H%M)
     - env PAGER= git tag
 
   # configure env
   configure-env:
+    desc: check that .env exists (copy from .env.dist if missing)
     cmds:
       - |
         if ! test -e .env
@@ -184,7 +247,8 @@
         fi
 
   # update configuration files from openwhisk source
-  update-files-from-openwhisk: 
+  update-files-from-openwhisk:
+    desc: update configuration files copied from the openwhisk source repo
     ignore_error: true
     cmds:
       - | 
@@ -201,11 +265,13 @@
 
   # generate ssh keys
   ssh-key:
+    desc: generate an ssh key pair for cluster access if missing
     cmds:
       - test -f clusters/id_rsa || ssh-keygen -b 2048 -t rsa -f clusters/id_rsa -q -N ""
       - ssh-keygen  -y -f clusters/id_rsa >clusters/id_rsa.pub
 
   clean:
+    desc: delete all deployed resources in the nuvolaris namespace
     cmds:
       - cmd: kubectl -n nuvolaris delete wsku --all
         ignore_error: true
@@ -230,6 +296,7 @@
 
 
   utest:
+    desc: run python doctests for nuvolaris modules (T=filter)
     cmds:
       - |
         for test in nuvolaris/{{.T}}*.py
@@ -238,9 +305,13 @@
         done
     silent: true
 
-  iclean: rm -f deploy/*/kustomization.yaml deploy/*/__* deploy/*/*_generated.yaml
+  iclean:
+    desc: remove generated kustomization and generated deploy files
+    cmds:
+    - rm -f deploy/*/kustomization.yaml deploy/*/__* deploy/*/*_generated.yaml
 
   itest:
+    desc: run ipython integration tests against the current kube (T=filter)
     cmds:
       - task: iclean
       - |
@@ -265,6 +336,7 @@
     silent: true
 
   dtest:
+    desc: deploy permissions, operator and instance, then run the actions tests
     cmds:
       - task: permission
       - task: operator
@@ -272,24 +344,31 @@
       - task: actions
 
   actions:
+    desc: run config, hello, redis and echo action tests
+    cmds:
     - task: t:config
     - task: t:hello
     - task: t:redis
     - task: t:echo
 
   test:
+    desc: run the full test suite (clean, unit, integration, deploy tests)
+    cmds:
     - task: clean
     - task: utest
     - task: itest
     - task: dtest
 
-  debug: 
+  debug:
+    desc: set up an ipython debug profile and run the ui integration test
+    cmds:
     - poetry run ipython profile create
     - cp test_profile.ipy ~/.ipython/profile_default/startup/
     - task: uitest
 
-  all-kubes: 
-    cmds: 
+  all-kubes:
+    desc: run a task against every configured kube (usage - task all-kubes -- <target>)
+    cmds:
       - |-
         if test -z "{{.CLI_ARGS}}"
         then echo 'use "task all-kubes -- <target> runs the target against all the available kubes'
@@ -301,11 +380,13 @@
         fi
     silent: true
 
-  kube-test: 
+  kube-test:
+    desc: run dtest quietly, discarding all output
     cmds:
       - task dtest 2>/dev/null >/dev/null
 
-  use: 
+  use:
+    desc: "list available kubeconfigs, or switch to the one matching N"
     cmds:
     - |-
         if test -z "{{.N}}"
@@ -320,15 +401,23 @@
     silent: true
 
   build-and-load:
+    desc: build the operator image and load it into the local kind cluster
+    cmds:
     - task: b:build-and-load
 
   build-and-push:
+    desc: build the operator image and push it to the registry
+    cmds:
     - task: b:build-and-push
 
   buildx-and-push:
+    desc: build the operator image (multi-arch) and push it to the registry
+    cmds:
     - task: b:buildx-and-push
 
   docker-login:
+    desc: log in to the docker registry
+    cmds:
     - task: b:docker-login
 
   # openserverless-operator section
@@ -398,15 +487,35 @@
         
 
   shell:
+    desc: open an interactive shell in the given pod (POD=name)
+    cmds:
     - kubectl -n nuvolaris exec --stdin --tty {{.POD}} -- /bin/bash
-      
-  1: task use N=1
-  2: task use N=2
-  3: task use N=3
-  4: task use N=4
-  5: task use N=5
-  6: task use N=6
-  7: task use N=7
-  8: task use N=8
-  9: task use N=9
+
+  1:
+    cmds:
+    - task use N=1
+  2:
+    cmds:
+    - task use N=2
+  3:
+    cmds:
+    - task use N=3
+  4:
+    cmds:
+    - task use N=4
+  5:
+    cmds:
+    - task use N=5
+  6:
+    cmds:
+    - task use N=6
+  7:
+    cmds:
+    - task use N=7
+  8:
+    cmds:
+    - task use N=8
+  9:
+    cmds:
+    - task use N=9
 
diff --git a/TaskfileDev.yml b/TaskfileDev.yml
index c7dc981..d5c7503 100644
--- a/TaskfileDev.yml
+++ b/TaskfileDev.yml
@@ -68,6 +68,8 @@
       COUCHDB_SERVICE_HOST: "localhost"
       MINIO_API_HOST: "localhost"
       OW_CONTROLLER_HOST : "localhost"
+      VIRTUAL_ENV:
+        sh: poetry env info --path 2>/dev/null || echo ""
       OW_CONTROLLER_PORT : 3233 
       INVOKER_IMAGE: "{{.INVOKER_IMAGE}}"
       INVOKER_TAG: "{{.INVOKER_TAG}}"
diff --git a/actions/devel/download/nuvolaris/couchdb_util.py b/actions/devel/download/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/actions/devel/download/nuvolaris/couchdb_util.py
+++ b/actions/devel/download/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/actions/devel/ferretdb/nuvolaris/couchdb_util.py b/actions/devel/ferretdb/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/actions/devel/ferretdb/nuvolaris/couchdb_util.py
+++ b/actions/devel/ferretdb/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/actions/devel/minio/nuvolaris/couchdb_util.py b/actions/devel/minio/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/actions/devel/minio/nuvolaris/couchdb_util.py
+++ b/actions/devel/minio/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/actions/devel/psql/nuvolaris/couchdb_util.py b/actions/devel/psql/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/actions/devel/psql/nuvolaris/couchdb_util.py
+++ b/actions/devel/psql/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/actions/devel/redis/nuvolaris/couchdb_util.py b/actions/devel/redis/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/actions/devel/redis/nuvolaris/couchdb_util.py
+++ b/actions/devel/redis/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/actions/devel/upload/nuvolaris/couchdb_util.py b/actions/devel/upload/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/actions/devel/upload/nuvolaris/couchdb_util.py
+++ b/actions/devel/upload/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/deploy/nuvolaris-permissions/whisk-user-crd.yaml b/deploy/nuvolaris-permissions/whisk-user-crd.yaml
index 2fc7402..878805f 100644
--- a/deploy/nuvolaris-permissions/whisk-user-crd.yaml
+++ b/deploy/nuvolaris-permissions/whisk-user-crd.yaml
@@ -69,7 +69,10 @@
                   type: string
                 namespace:
                   description: ow namespace assigned to the user
-                  type: string 
+                  type: string
+                  x-kubernetes-validations:
+                    - rule: "self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')"
+                      message: "Invalid namespace name"
                 auth:
                   description: ow auth used to authenticate the user
                   type: string
@@ -87,6 +90,9 @@
                     prefix:
                       description: redis key prefixused to configure a user custom made ACL
                       type: string
+                      x-kubernetes-validations:
+                        - rule: "self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')"
+                          message: "Invalid redis username name"
                     password:
                       description: user redis password
                       type: string
diff --git a/nuvolaris/couchdb.py b/nuvolaris/couchdb.py
index e6ebf6a..6a38cf7 100644
--- a/nuvolaris/couchdb.py
+++ b/nuvolaris/couchdb.py
@@ -43,8 +43,8 @@
     user = f"db_username={u}"
     pasw = f"db_password={p}"
 
-    img = cfg.get('operator.image') or "missing-operator-image"
-    tag = cfg.get('operator.tag') or "missing-operator-tag"
+    img = cfg.get('operator.image', 'OPERATOR_IMAGE', "missing-operator-image")
+    tag = cfg.get('operator.tag', 'OPERATOR_TAG', "missing-operator-tag")
     image = f"{img}:{tag}"
     container_image = runtime in ['openshift'] and "ghcr.io/nuvolaris/couchdb:2.3.1-nuvolaris.23101915" or "apache/couchdb:2.3"
 
diff --git a/nuvolaris/couchdb_util.py b/nuvolaris/couchdb_util.py
index 7a18899..b518a2d 100644
--- a/nuvolaris/couchdb_util.py
+++ b/nuvolaris/couchdb_util.py
@@ -40,7 +40,7 @@
       session = req.Session()
       while delta < max_seconds:
         try:
-          r = session.get(f"{self.db_url}/_utils", timeout=5)
+          r = session.get(f"{self.db_url}/_utils/", timeout=5)
           logging.info(f"CouchDB.wait_db_ready() got response code = {r.status_code}")          
           if r.status_code == 200:
             return True
diff --git a/nuvolaris/cronjob.py b/nuvolaris/cronjob.py
index a0529d3..c02f375 100644
--- a/nuvolaris/cronjob.py
+++ b/nuvolaris/cronjob.py
@@ -24,8 +24,8 @@
 def create(owner=None):
     logging.info("creating cron")
     
-    img = cfg.get('operator.image') or "missing-operator-image"
-    tag = cfg.get('operator.tag') or "missing-operator-tag"
+    img = cfg.get("operator.image","OPERATOR_IMAGE", "ghcr.io/nuvolaris/openwhisk-controller")
+    tag = cfg.get("operator.tag","OPERATOR_TAG", "3.1.0-mastrogpt.2402101445")
     image = f"{img}:{tag}"
     logging.info(f"cron using image {image}")
 
diff --git a/nuvolaris/enterprise_util.py b/nuvolaris/enterprise_util.py
index fd0d0bb..bd86f4f 100644
--- a/nuvolaris/enterprise_util.py
+++ b/nuvolaris/enterprise_util.py
@@ -43,9 +43,10 @@
         img_data = util.parse_image(invoker_image)
         data['invoker_image'] = img_data["image"]
         data['invoker_tag'] = img_data["tag"]
-    else:        
-        data['invoker_image'] = cfg.get("invoker.image") or "ghcr.io/nuvolaris/openwhisk-invoker"
-        data['invoker_tag'] = cfg.get("invoker.tag") or "3.1.0-mastrogpt.2402101445"    
+    else:
+        #ghcr.io/nuvolaris/openwhisk-invoker:3.1.0-mastrogpt.2402101445
+        data['invoker_image'] = cfg.get("invoker.image","INVOKER_IMAGE", "ghcr.io/nuvolaris/openwhisk-invoker")
+        data['invoker_tag'] = cfg.get("invoker.tag", "INVOKER_TAG", "3.1.0-mastrogpt.2402101445")
 
 def getEnterpriseControllerConfigData():
     data = {
diff --git a/nuvolaris/ingress_data.py b/nuvolaris/ingress_data.py
index 1ac7e3d..c564a97 100644
--- a/nuvolaris/ingress_data.py
+++ b/nuvolaris/ingress_data.py
@@ -110,6 +110,8 @@
         """
         uses the given template to render a final ingress template and returns the path to the template
         """
+        if not util.validate_namespace(namespace):
+            raise ValueError(f"Invalid namespace {namespace}")
         logging.info(f"*** Rendering ingress template using host {self._data['hostname']} endpoint for {self._data['ingress_name']} via template {tpl}")  
         out = f"/tmp/__{namespace}_{tpl}"
         file = ntp.spool_template(tpl, out, self._data)
@@ -118,7 +120,9 @@
     def render_traefik_middleware_template(self, namespace,tpl="traefik-middleware-tpl.yaml"):
         """
         uses the given template policy to render a final ingress template. By default renders an addPrefix middleware.
-        """  
+        """ 
+        if not util.validate_namespace(namespace):
+            raise ValueError(f"Invalid namespace {namespace}")
         logging.info(f"*** Rendering traefik middleware template using host {self._data['hostname']} endpoint for {self._data['ingress_name']} via template {tpl}")
         out = f"/tmp/__{namespace}_{tpl}"
         file = ntp.spool_template(tpl, out, self._data)
diff --git a/nuvolaris/invoker.py b/nuvolaris/invoker.py
index 92c673f..0ae65dd 100644
--- a/nuvolaris/invoker.py
+++ b/nuvolaris/invoker.py
@@ -39,8 +39,8 @@
     if(data['affinity'] or data['tolerations']):
        tplp.append("affinity-tolerance-sts-invoker-attach.yaml")
     
-    config = kus.image(whisk_image, newTag=whisk_tag)
-    config += kus.patchTemplates("openwhisk-invoker", tplp, data)
+    logging.info(f"using invoker image {whisk_image}:{whisk_tag}")
+    config = kus.patchTemplates("openwhisk-invoker", tplp, data)
     spec = kus.kustom_list("openwhisk-invoker", config, templates=[], data=data)
 
     if owner:
diff --git a/nuvolaris/mongodb.py b/nuvolaris/mongodb.py
index 50bf5c8..58c5ea9 100644
--- a/nuvolaris/mongodb.py
+++ b/nuvolaris/mongodb.py
@@ -114,12 +114,16 @@
 def render_mongodb_script(namespace,template,data):
     """
     uses the given template to render a js script to execute as a json.
-    """  
+    """
+    if not util.validate_namespace(namespace):
+        raise ValueError(f"Invalid namespace {namespace}")
     out = f"/tmp/__{namespace}_{template}"
     file = ntp.spool_template(template, out, data)
     return os.path.abspath(file)
 
 def exec_mongosh_command(pod_name,path_to_mdb_script):
+    if not os.path.exists(path_to_mdb_script):
+        raise ValueError(f"invalid path script in exec_mongosh_command")
     logging.info(f"passing script {path_to_mdb_script} to pod {pod_name}")
     res = kube.kubectl("cp",path_to_mdb_script,f"{pod_name}:{path_to_mdb_script}")
     res = kube.kubectl("exec","-it",pod_name,"--","/bin/bash","-c",f"mongosh --file {path_to_mdb_script}")
diff --git a/nuvolaris/openwhisk_enterprise.py b/nuvolaris/openwhisk_enterprise.py
index 6fb6a19..1c4d574 100644
--- a/nuvolaris/openwhisk_enterprise.py
+++ b/nuvolaris/openwhisk_enterprise.py
@@ -36,8 +36,8 @@
     if(data['affinity'] or data['tolerations']):
        tplp.append("affinity-tolerance-sts-core-attach.yaml")    
 
-    config = kus.image(whisk_image, newTag=whisk_tag)
-    config += kus.patchTemplates("openwhisk-enterprise", tplp, data)
+    logging.info(f"using controller image {whisk_image}:{whisk_tag}") 
+    config = kus.patchTemplates("openwhisk-enterprise", tplp, data)
     spec = kus.kustom_list("openwhisk-enterprise", config, templates=[], data=data)
     
     if owner:
diff --git a/nuvolaris/openwhisk_standalone.py b/nuvolaris/openwhisk_standalone.py
index f44bc3d..1d53d43 100644
--- a/nuvolaris/openwhisk_standalone.py
+++ b/nuvolaris/openwhisk_standalone.py
@@ -31,13 +31,14 @@
 
     whisk_image = data["controller_image"]
     whisk_tag = data["controller_tag"]
-    config = kus.image(whisk_image, newTag=whisk_tag)
-    
+
+    logging.info(f"using controller image {whisk_image}:{whisk_tag}")
+
     tplp = ["standalone-sts.yaml"]
     if(data['affinity'] or data['tolerations']):
        tplp.append("affinity-tolerance-sts-core-attach.yaml")
 
-    config += kus.patchTemplates("openwhisk-standalone", tplp, data)
+    config = kus.patchTemplates("openwhisk-standalone", tplp, data)
     spec = kus.kustom_list("openwhisk-standalone", config, templates=[], data=data)
 
     if owner:
diff --git a/nuvolaris/postgres_operator.py b/nuvolaris/postgres_operator.py
index fb11659..7274e87 100644
--- a/nuvolaris/postgres_operator.py
+++ b/nuvolaris/postgres_operator.py
@@ -173,12 +173,16 @@
 def render_postgres_script(namespace,template,data):
     """
     uses the given template to render a sh script to execute via psql.
-    """  
+    """
+    if not util.validate_namespace(namespace):
+        raise ValueError(f"Invalid namespace {namespace}")
     out = f"/tmp/__{namespace}_{template}"
     file = ntp.spool_template(template, out, data)
     return os.path.abspath(file)
 
 def exec_psql_command(pod_name,path_to_psql_script,path_to_pgpass,additional_psql_args=''):
+    if not os.path.exists(path_to_psql_script):
+        raise ValueError(f"invalid path script in exec_mongosh_command")
     logging.info(f"passing script {path_to_psql_script} to pod {pod_name}")
     res = kube.kubectl("cp",path_to_psql_script,f"{pod_name}:{path_to_psql_script}")
     res = kube.kubectl("cp",path_to_pgpass,f"{pod_name}:/tmp/.pgpass")
diff --git a/nuvolaris/quota_checker_job.py b/nuvolaris/quota_checker_job.py
index 534d447..d67c552 100644
--- a/nuvolaris/quota_checker_job.py
+++ b/nuvolaris/quota_checker_job.py
@@ -24,8 +24,8 @@
 def create(owner=None):
     logging.info("creating quota cheker scheduled job")
    
-    img = cfg.get('operator.image') or "missing-operator-image"
-    tag = cfg.get('operator.tag') or "missing-operator-tag"
+    img = cfg.get('operator.image','OPERATOR_IMAGE') or "missing-operator-image"
+    tag = cfg.get('operator.tag','OPERATOR_TAG') or "missing-operator-tag"
 
     image = f"{img}:{tag}"
     logging.info("quota job using image %s", image)
diff --git a/nuvolaris/redis.py b/nuvolaris/redis.py
index f6ed2f8..fe51440 100644
--- a/nuvolaris/redis.py
+++ b/nuvolaris/redis.py
@@ -152,12 +152,19 @@
 def render_redis_script(namespace,template,data):
     """
     uses the given template to render a redis-cli script to be executed.
-    """  
+    """
+    if not util.validate_namespace(namespace):
+        raise ValueError(f"Invalid namespace {namespace}")       
+    
     out = f"/tmp/__{namespace}_{template}"
     file = ntp.spool_template(template, out, data)
     return os.path.abspath(file)
 
 def exec_redis_command(pod_name,path_to_script):
+    if not os.path.exists(path_to_script):
+        raise ValueError(f"invalid path script in exec_redis_command")
+    
+    
     logging.info(f"passing script {path_to_script} to pod {pod_name}")
     res = kube.kubectl("cp",path_to_script,f"{pod_name}:{path_to_script}")
     res = kube.kubectl("exec","-it",pod_name,"--","/bin/bash","-c",f"cat {path_to_script} | redis-cli")
diff --git a/nuvolaris/route_data.py b/nuvolaris/route_data.py
index 63a9b0b..7e6b0e8 100644
--- a/nuvolaris/route_data.py
+++ b/nuvolaris/route_data.py
@@ -81,7 +81,9 @@
         logging.info(f"*** Rendering route template using host {self._data['hostname']} endpoint for {self._data['route_name']} via template {tpl}")
         """
         uses the given template to render a final route template and returns the path to the template
-        """  
+        """
+        if not util.validate_namespace(namespace):
+            raise ValueError(f"Invalid namespace {namespace}")
         out = f"/tmp/__{namespace}_{tpl}"
         file = ntp.spool_template(tpl, out, self._data)
         return os.path.abspath(file)                  
\ No newline at end of file
diff --git a/nuvolaris/secret_htpasswd_data.py b/nuvolaris/secret_htpasswd_data.py
index 7a636d4..821aa14 100644
--- a/nuvolaris/secret_htpasswd_data.py
+++ b/nuvolaris/secret_htpasswd_data.py
@@ -20,6 +20,7 @@
 import os
 import nuvolaris.kustomize as kus
 import nuvolaris.template as ntp
+import nuvolaris.util as util
 import bcrypt
 import base64   
 
@@ -59,6 +60,8 @@
         """
         uses the given template to render a final htpassword secret template and returns the path to the template
         """
+        if not util.validate_namespace(namespace):
+            raise ValueError(f"Invalid namespace {namespace}")
         logging.info(f"*** Rendering htpassword secret template with name {self._data['secret_name']} via template {tpl}")
         out = f"/tmp/__{namespace}_{tpl}"
         file = ntp.spool_template(tpl, out, self._data)
diff --git a/nuvolaris/secret_imagepull_data.py b/nuvolaris/secret_imagepull_data.py
index f842d13..03e9d13 100644
--- a/nuvolaris/secret_imagepull_data.py
+++ b/nuvolaris/secret_imagepull_data.py
@@ -20,6 +20,7 @@
 import os
 import nuvolaris.kustomize as kus
 import nuvolaris.template as ntp
+import nuvolaris.util as util
 import base64   
 
 class ImagePullSecretData:
@@ -65,6 +66,8 @@
         """
         uses the given template to render a final ImagePull secret template and returns the path to the template
         """
+        if not util.validate_namespace(namespace):
+            raise ValueError(f"Invalid namespace {namespace}")
         logging.info(f"*** Rendering ImagePull secret template with name {self._data['secret_name']} via template {tpl}")
         out = f"/tmp/__{namespace}_{self._data['secret_name']}_{tpl}"
         file = ntp.spool_template(tpl, out, self._data)
diff --git a/nuvolaris/templates/enterprise-sts.yaml b/nuvolaris/templates/enterprise-sts.yaml
index db8320a..3511f81 100644
--- a/nuvolaris/templates/enterprise-sts.yaml
+++ b/nuvolaris/templates/enterprise-sts.yaml
@@ -48,7 +48,7 @@
       containers:
       - name: controller
         imagePullPolicy: "IfNotPresent"
-        image: "ghcr.io/nuvolaris/openwhisk-controller:0.3.0-morpheus.22122609"      
+        image: "{{controller_image}}:{{controller_tag}}"
         command: ["/bin/bash", "-c", "/init.sh `hostname | awk -F '-' '{print $NF}'`"]     
         ports:
         - name: controller
diff --git a/nuvolaris/templates/invoker-sts.yaml b/nuvolaris/templates/invoker-sts.yaml
index 1f27bcf..e84a38d 100644
--- a/nuvolaris/templates/invoker-sts.yaml
+++ b/nuvolaris/templates/invoker-sts.yaml
@@ -47,7 +47,7 @@
         command: ['sh', '-c', "until nslookup kafka.$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace).svc.cluster.local; do echo waiting for kafka; sleep 2; done"]     
       containers:
       - name: invoker
-        image: "ghcr.io/nuvolaris/openwhisk-invoker:0.3.0-morpheus.22122609"
+        image: "{{invoker_image}}:{{invoker_tag}}"
         imagePullPolicy: "IfNotPresent"
         command: [ "/bin/bash", "-c", "/init.sh --uniqueName $INVOKER_NAME" ]
         #command: ["sleep", "inf"]
diff --git a/nuvolaris/templates/standalone-sts.yaml b/nuvolaris/templates/standalone-sts.yaml
index 2ee9da8..611f502 100644
--- a/nuvolaris/templates/standalone-sts.yaml
+++ b/nuvolaris/templates/standalone-sts.yaml
@@ -45,7 +45,7 @@
       containers:
       - name: controller
         imagePullPolicy: "IfNotPresent"
-        image: "ghcr.io/nuvolaris/openwhisk-controller:0.3.0-morpheus.22122609"       
+        image: "{{controller_image}}:{{controller_tag}}"
         command: ["/bin/bash", "-c", "/init.sh `hostname | awk -F '-' '{print $NF}'`"]             
         ports:
         - name: controller
diff --git a/nuvolaris/util.py b/nuvolaris/util.py
index b616851..34a1bb2 100644
--- a/nuvolaris/util.py
+++ b/nuvolaris/util.py
@@ -22,6 +22,7 @@
 import time
 import uuid
 import os
+import re
 from base64 import b64decode, b64encode
 from typing import List, Union
 from urllib.parse import urlparse
@@ -345,8 +346,8 @@
         data['controller_image'] = img_data["image"]
         data['controller_tag'] = img_data["tag"]
     else:
-        data['controller_image'] = cfg.get("controller.image") or "ghcr.io/nuvolaris/openwhisk-controller"
-        data['controller_tag'] = cfg.get("controller.tag") or "3.1.0-mastrogpt.2402101445"
+        data['controller_image'] = cfg.get("controller.image","CONTROLLER_IMAGE", "ghcr.io/nuvolaris/openwhisk-controller")
+        data['controller_tag'] = cfg.get("controller.tag","CONTROLLER_TAG", "3.1.0-mastrogpt.2402101445")
 
 # return configuration parameters for the standalone controller
 def get_standalone_config_data():
@@ -386,6 +387,17 @@
     standalone_affinity_tolerations_data(data)
     return data
 
+def validate_namespace(namespace: str) -> bool:
+    """
+        >>> import nuvolaris.util as util
+        >>> util.validate_namespace("demouser")
+        True
+        >>> util.validate_namespace('x;id;#')
+        False
+    """
+    NAMESPACE_RE = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$")
+    return bool(NAMESPACE_RE.fullmatch(namespace))
+
 def validate_ow_auth(auth):
     """
         >>> import nuvolaris.testutil as tutil
diff --git a/nuvolaris/zookeeper.py b/nuvolaris/zookeeper.py
index b7edb6a..0ba5197 100644
--- a/nuvolaris/zookeeper.py
+++ b/nuvolaris/zookeeper.py
@@ -39,7 +39,7 @@
     return None    
 
 def create(owner=None):
-    logging.info(f"*** configuring zookeeper")
+    logging.info("*** configuring zookeeper")
 
     data = cfg_util.get_zookeeper_config_data()
     
diff --git a/run.sh b/run.sh
index 8a40774..38f9f11 100755
--- a/run.sh
+++ b/run.sh
@@ -26,7 +26,11 @@
      cd ..
 fi
 # start the operator if possible
+KOPF="${VIRTUAL_ENV:-/home/nuvolaris/.venv}/bin/kopf"
 if kubectl -n nuvolaris get cm/config
-then exec /home/nuvolaris/.venv/bin/kopf run -n nuvolaris -m nuvolaris nuvolaris/main.py nuvolaris/user_handlers.py nuvolaris/workflows.py "$@"
+then if test -x "$KOPF"
+     then exec "$KOPF" run -n nuvolaris -m nuvolaris nuvolaris/main.py nuvolaris/user_handlers.py nuvolaris/workflows.py "$@"
+     else echo "kopf not found at $KOPF - run 'task setup' first."
+     fi
 else echo "You need to 'kubectl apply -f deploy/permissions' before starting the operator."
 fi
diff --git a/tests/kind/milvus_standalone_slim_test.ipy b/tests/kind/milvus_standalone_slim_test.ipy
index edd404a..2480dbe 100644
--- a/tests/kind/milvus_standalone_slim_test.ipy
+++ b/tests/kind/milvus_standalone_slim_test.ipy
@@ -22,6 +22,7 @@
 import nuvolaris.config as cfg
 import nuvolaris.milvus_standalone as milvus
 import nuvolaris.minio_deploy as minio
+import nuvolaris.seaweedfs_deploy as seaweedfs
 import nuvolaris.etcd as etcd
 import nuvolaris.testutil as tu
 from nuvolaris.milvus_simple_client import MilvusSimpleClient as MilvusClient
@@ -31,21 +32,37 @@
 
 #tu.enable_debug_logging()
 ### Initial cleanup
-!kubectl -n nuvolaris delete all --all
-!kubectl -n nuvolaris delete pvc --all
+tu.run_proc("kubectl -n nuvolaris delete all --all")
+tu.run_proc("kubectl -n nuvolaris delete pvc --all")
 
 # test
 
 assert(cfg.configure(tu.load_sample_config("kind/whisk-slim")))
 assert(cfg.detect_storage()["nuvolaris.storageclass"])
 assert(cfg.get("nuvolaris.slim") == True)
+assert(cfg.get("components.milvus") == True)
+
+is_minio = cfg.get('components.minio') or False
+is_seaweedfs = cfg.get('components.seaweedfs') or False
+assert(is_minio or is_seaweedfs)
+
 
 # for this test minioClient and Milvus should see this env variable
-os.environ['MINIO_API_HOST']='localhost'
+if is_minio:
+    os.environ['MINIO_API_HOST']='localhost'
+if is_seaweedfs:
+    os.environ['SEAWEEDFS_API_HOST']='localhost'
+    os.environ['SEAWEEDFS_API_PORT']='9090'
+
 os.environ['MILVUS_API_HOST']='localhost'
 
 assert(etcd.create())
-assert(minio.create())
+
+if is_minio:
+   assert(minio.create())
+if is_seaweedfs:
+    assert(seaweedfs.create())
+
 assert(milvus.create())
 
 # generate some user config
@@ -86,7 +103,8 @@
 # test client
 client = MilvusClient(uri=f'http://localhost:{port}',token=token, db_name=database)
 client.create_collection("testcollection", dimension=128)
-assert("testcollection" in client.list_collections())
+collections = client.list_collections() or []
+assert("testcollection" in collections)
 client.drop_collection("testcollection")
 
 # test user deletion
@@ -94,9 +112,12 @@
 
 assert(milvus.delete())
 assert(etcd.delete())
-assert(minio.delete())
+if is_minio:
+   assert(minio.delete())    
+if is_seaweedfs:
+    assert(seaweedfs.delete())
 
 # final cleanup
-!kubectl -n nuvolaris delete all --all
+tu.run_proc("kubectl -n nuvolaris delete all --all")
 # etcd pvc in particular
-!kubectl -n nuvolaris delete pvc --all
\ No newline at end of file
+tu.run_proc("kubectl -n nuvolaris delete pvc --all")
diff --git a/tests/kind/milvus_standalone_test.ipy b/tests/kind/milvus_standalone_test.ipy
index 26faeae..4011c4e 100644
--- a/tests/kind/milvus_standalone_test.ipy
+++ b/tests/kind/milvus_standalone_test.ipy
@@ -23,6 +23,7 @@
 import nuvolaris.etcd as etcd
 import nuvolaris.milvus_standalone as milvus
 import nuvolaris.minio_deploy as minio
+import nuvolaris.seaweedfs_deploy as seaweedfs
 import nuvolaris.testutil as tu
 from nuvolaris.milvus_simple_client import MilvusSimpleClient as MilvusClient
 from nuvolaris.user_config import UserConfig
@@ -39,13 +40,27 @@
 assert(cfg.configure(tu.load_sample_config()))
 assert(cfg.detect_storage()["nuvolaris.storageclass"])
 assert(cfg.get("nuvolaris.slim") == None or cfg.get("nuvolaris.slim") == False)
+assert(cfg.get("components.milvus") == True)
+
+is_minio = cfg.get('components.minio') or False
+is_seaweedfs = cfg.get('components.seaweedfs') or False
+assert(is_minio or is_seaweedfs)
+
 
 # for this test minioClient and Milvus should see this env variable
-os.environ['MINIO_API_HOST']='localhost'
+# for this test minioClient and Milvus should see this env variable
+if is_minio:
+    os.environ['MINIO_API_HOST']='localhost'
+if is_seaweedfs:
+    os.environ['SEAWEEDFS_API_HOST']='localhost'
+    os.environ['SEAWEEDFS_API_PORT']='9090'
 os.environ['MILVUS_API_HOST']='localhost'
 
 assert(etcd.create())
-assert(minio.create())
+if is_minio:
+   assert(minio.create())
+if is_seaweedfs:
+    assert(seaweedfs.create())
 assert(milvus.create())
 
 # generate some user config
@@ -85,7 +100,9 @@
 # test client
 client = MilvusClient(uri=f'http://localhost:{port}',token=token, db_name=database)
 client.create_collection("testcollection", dimension=128)
-assert("testcollection" in client.list_collections())
+collections = client.list_collections() or []
+assert("testcollection" in collections)
+
 client.drop_collection("testcollection")
 
 # test user deletion
@@ -93,7 +110,10 @@
 
 assert(milvus.delete())
 assert(etcd.delete())
-assert(minio.delete())
+if is_minio:
+   assert(minio.delete())    
+if is_seaweedfs:
+    assert(seaweedfs.delete())
 
 tu.run_proc("kubectl -n nuvolaris delete all --all")
 tu.run_proc("kubectl -n nuvolaris delete pvc --all")
diff --git a/tests/kind/nuvolaris_subject_test.ipy b/tests/kind/nuvolaris_subject_test.ipy
index 6436dc8..242b67d 100644
--- a/tests/kind/nuvolaris_subject_test.ipy
+++ b/tests/kind/nuvolaris_subject_test.ipy
@@ -16,23 +16,25 @@
 # under the License.
 #
 
-!kubectl -n nuvolaris delete all --all
-!kubectl -n nuvolaris delete pvc --all
-
 import nuvolaris.couchdb as cdb
 import nuvolaris.couchdb_util as cdbu
 import nuvolaris.testutil as tu
 import nuvolaris.config as cfg
 import nuvolaris.kube as kube
+import nuvolaris.testutil as tu
 import time
 from kopf.testing import KopfRunner
 
+tu.run_proc("kubectl -n nuvolaris delete all --all")
+tu.run_proc("kubectl -n nuvolaris delete pvc --all")
+
+
 assert(cfg.configure(tu.load_sample_config()))
 assert(cfg.detect_labels()["nuvolaris.kube"] == "kind")
 assert(cfg.detect_storage()["nuvolaris.storageclass"] == "standard")
 assert(cfg.put("couchdb.host", "localhost"))
 
-!kubectl apply -f tests/kind/whisk.yaml
+tu.run_proc("kubectl apply -f tests/kind/whisk.yaml")
 wsk = kube.get("wsk/controller")
 cdb.create(wsk)
 
@@ -51,17 +53,17 @@
 
 with KopfRunner(['run', '-A', '--verbose', 'nuvolaris/user_handlers.py']) as runner:
     # do something while the operator is running.
-    !kubectl apply -f tests/whisk-user.yaml
+    tu.run_proc("kubectl apply -f tests/whisk-user.yaml")
     time.sleep(1)  # give it some time to react and to sleep and to retry
     wsku = kube.get("wsku/franztt")    
     assert(wsku['spec'])
 
-    !kubectl delete -f tests/whisk-user.yaml
+    tu.run_proc("kubectl delete -f tests/whisk-user.yaml")
     time.sleep(1)  # give it some time to react
 
 assert runner.exit_code == 0
 assert runner.exception is None
 
-!kubectl -n nuvolaris delete all --all
-!kubectl -n nuvolaris delete pvc --all
+tu.run_proc("kubectl -n nuvolaris delete all --all")
+tu.run_proc("kubectl -n nuvolaris delete pvc --all")
 
diff --git a/tests/kind/userdb_util_test.ipy b/tests/kind/userdb_util_test.ipy
index ec3b04e..84d4272 100644
--- a/tests/kind/userdb_util_test.ipy
+++ b/tests/kind/userdb_util_test.ipy
@@ -15,10 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 #
-
-!kubectl -n nuvolaris delete all --all
-!kubectl -n nuvolaris delete pvc --all
-
 import json
 
 import nuvolaris.config as cfg
@@ -30,13 +26,18 @@
 import nuvolaris.user_metadata as user_metadata
 import nuvolaris.userdb_util as userdb
 import nuvolaris.bcrypt_util as bu
+import nuvolaris.testutil as tu
+
+tu.run_proc("kubectl -n nuvolaris delete all --all")
+tu.run_proc("kubectl -n nuvolaris delete pvc --all")
+
 
 assert(cfg.configure(tu.load_sample_config()))
 assert(cfg.detect_labels()["nuvolaris.kube"] == "kind")
 assert(cfg.detect_storage()["nuvolaris.storageclass"] == "standard")
 assert(cfg.put("couchdb.host", "localhost"))
 
-!kubectl apply -f tests/kind/whisk.yaml
+tu.run_proc("kubectl apply -f tests/kind/whisk.yaml")
 wsk = kube.get("wsk/controller")
 cdb.create(wsk)
 
@@ -49,7 +50,8 @@
 assert(cdb.init_users_metadata(db))
 
 # test user metadata creation
-!kubectl apply -f tests/kind/whisk-user.yaml
+tu.run_proc("kubectl apply -f tests/kind/whisk-user.yaml")
+
 wsku = kube.get("wsku/franztt")
 ucfg = user_config.UserConfig(wsku['spec'])
 metadata = user_metadata.UserMetadata(ucfg)
@@ -64,7 +66,7 @@
 
 # test password verification
 doc = docs[0]
-assert(bu.verify_password(ucfg.get('password'),doc['password']))
+assert(bu.verify_password(ucfg.get('password') or '',doc['password']))
 
 # test password change
 new_password = 'test123'
@@ -83,6 +85,6 @@
 assert(len(docs) == 0)
 
 # cleanup
-!kubectl -n nuvolaris delete all --all
-!kubectl -n nuvolaris delete pvc --all
+tu.run_proc("kubectl -n nuvolaris delete all --all")
+tu.run_proc("kubectl -n nuvolaris delete pvc --all")
 
diff --git a/tests/kind/whisk-minimal.yaml b/tests/kind/whisk-minimal.yaml
index c3fc351..6b8afcc 100644
--- a/tests/kind/whisk-minimal.yaml
+++ b/tests/kind/whisk-minimal.yaml
@@ -25,11 +25,13 @@
     # start openwhisk controller
     openwhisk: true
     # start openwhisk invoker
-    invoker: false    
+    invoker: true    
     # start couchdb
     couchdb: true
     # start kafka
-    kafka: false
+    kafka: true
+    # zookeeper enabled or not
+    zookeeper: true
     # start mongodb
     mongodb: false
     # start redis
diff --git a/tests/kind/whisk-slim.yaml b/tests/kind/whisk-slim.yaml
index f6f3f4a..8357bb4 100644
--- a/tests/kind/whisk-slim.yaml
+++ b/tests/kind/whisk-slim.yaml
@@ -41,19 +41,19 @@
     # tls enabled or not
     tls: false
     # minio enabled or not
-    minio: true 
+    minio: false 
     # minio static enabled or not
     static: true
     # postgres enabled or not
     postgres: true
     # etcd enabled or not
-    etcd: true
+    etcd: false
     # milvus enabled or not
     milvus: true
     # registry enabled or not
     registry: false
     # seaweedfs enabled or not
-    seaweedfs: false               
+    seaweedfs: true      
   openwhisk:
     namespaces:
       whisk-system: 789c46b1-71f6-4ed5-8c54-816aa4f8c502:abczO3xZCLrMN6v2BKK1dXYFpXlPkccOFqm12CdAsMgRU4VrNZ9lyGVCGuMDGIwP