diff --git a/admin/scripts/validate-app.sh b/admin/scripts/validate-app.sh new file mode 100755 index 0000000..109b093 --- /dev/null +++ b/admin/scripts/validate-app.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Validate a Wild Cloud app package before submitting. +# +# Usage: +# From your app directory: ../../admin/scripts/validate-app.sh +# From wild-directory root: admin/scripts/validate-app.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WILD_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if [ $# -ge 1 ]; then + APP_NAME="$1" +else + CURRENT="$(pwd)" + if [[ "$CURRENT" == "$WILD_DIR"/* ]]; then + RELATIVE="${CURRENT#"$WILD_DIR"/}" + APP_NAME="${RELATIVE%%/*}" + else + echo "Usage: validate-app.sh " >&2 + echo " or run from inside your app directory." >&2 + exit 1 + fi +fi + +if [ ! -d "$WILD_DIR/$APP_NAME" ]; then + echo "App not found: $APP_NAME" >&2 + exit 1 +fi + +echo "Validating: $APP_NAME" +python3 "$SCRIPT_DIR/validate-apps.py" "$APP_NAME" diff --git a/admin/scripts/validate-apps.py b/admin/scripts/validate-apps.py new file mode 100755 index 0000000..3f652ee --- /dev/null +++ b/admin/scripts/validate-apps.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +"""Validate Wild Directory app packages for common authoring errors. + +Usage: + python3 admin/scripts/validate-apps.py [app-name ...] + python3 admin/scripts/validate-apps.py --list-rules + +With no arguments, validates all apps. Pass app names to check specific ones. +Exit code 1 if any errors are found. + +To suppress specific rules for an app, add ignoreRules to its app.yaml: + ignoreRules: + - WC-HELM + - WC-SEL +""" + +import os +import sys +import re +import yaml + +REQUIRED_APP_YAML_FIELDS = ["name", "is", "description", "category", "latest"] +REQUIRED_MANIFEST_FIELDS = ["version"] +STANDARD_LABEL_PAIRS = {"managedBy": "kustomize", "partOf": "wild-cloud"} +HELM_LABELS = {"app.kubernetes.io/name", "app.kubernetes.io/instance", "app.kubernetes.io/component"} +MUTABLE_IMAGE_TAGS = {"latest", "main", "master", "edge", "develop", "dev", "nightly", "stable"} +GLOBAL_TEMPLATE_NAMESPACES = {"cloud", "cluster", "operator", "apps", "secrets", "app"} + + +def load_rules(): + """Load rule definitions from validation-rules.yaml in the same directory.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + rules_path = os.path.join(script_dir, "validation-rules.yaml") + with open(rules_path) as f: + data = yaml.safe_load(f) + rules = {} + remediation = {} + severity = {} + for rule_id, info in (data or {}).items(): + if isinstance(info, dict): + rules[rule_id] = info.get("description", "") + severity[rule_id] = info.get("severity", "warning") + rem = info.get("remediation", "") + if rem: + remediation[rule_id] = rem.strip() + return rules, remediation, severity + + +RULES, REMEDIATION, SEVERITY = load_rules() + + +def add_error(errors, ignored_rules, rule_id, message): + if rule_id not in ignored_rules: + errors.append((rule_id, message)) + + +def add_warning(warnings, ignored_rules, rule_id, message): + if rule_id not in ignored_rules: + warnings.append((rule_id, message)) + + +def strip_gomplate(text): + """Replace gomplate expressions with a placeholder so the file parses as valid YAML.""" + return re.sub(r"\{\{.*?\}\}", "PLACEHOLDER", text, flags=re.DOTALL) + + +def load_yaml(path): + with open(path) as f: + content = f.read() + return yaml.safe_load(strip_gomplate(content)) + + +def load_yaml_all(path): + """Yield all YAML documents in a file (handles multi-doc files).""" + with open(path) as f: + content = f.read() + yield from yaml.safe_load_all(strip_gomplate(content)) + + +def resource_docs(kustomize_dir, resources): + """Yield (filepath, doc) for each YAML document in listed resource files.""" + for resource in resources: + path = os.path.join(kustomize_dir, resource) + if not os.path.isfile(path): + continue + try: + for doc in load_yaml_all(path): + if doc: + yield path, doc + except Exception: + pass + + +def resources_have_workloads(kustomize_dir, resources): + """Return True if any listed resource file contains a Deployment or StatefulSet.""" + for _, doc in resource_docs(kustomize_dir, resources): + if doc.get("kind") in ("Deployment", "StatefulSet"): + return True + return False + + +def check_security_context(spec, resource_name, errors, warnings, ignored_rules): + """Check pod-level and container-level security contexts.""" + pod_spec = spec.get("template", {}).get("spec", {}) if "template" in spec else spec + if not pod_spec: + return + + pod_sc = pod_spec.get("securityContext") + if not pod_sc: + add_warning(warnings, ignored_rules, "WC-SC-POD", + f"{resource_name}: no pod-level securityContext") + + for container in pod_spec.get("containers", []) + pod_spec.get("initContainers", []): + cname = container.get("name", "?") + csc = container.get("securityContext") + if not csc: + add_warning(warnings, ignored_rules, "WC-SC-CTR", + f"{resource_name}/{cname}: no container-level securityContext") + + +def check_mysql_db_init(kustomize_dir, resources, errors, warnings, ignored_rules): + """Check MySQL db-init SQL for the idempotent password pattern (WC-DBIN).""" + for resource in resources: + path = os.path.join(kustomize_dir, resource) + if not os.path.isfile(path): + continue + try: + with open(path) as f: + content = f.read() + except Exception: + continue + # Only files that contain MySQL-style SQL (IDENTIFIED BY is MySQL-specific) + if "CREATE USER" not in content or "IDENTIFIED BY" not in content: + continue + if "ALTER USER" not in content: + add_error(errors, ignored_rules, "WC-DBIN", + f"{os.path.basename(path)}: MySQL CREATE USER IF NOT EXISTS without ALTER USER" + " — passwords won't update on redeploy (see note #17)") + + +def check_docker_dns(kustomize_dir, resources, errors, warnings, ignored_rules): + """Check for Docker DNS resolver (127.0.0.11) which doesn't work in Kubernetes (WC-DKRDNS).""" + for resource in resources: + path = os.path.join(kustomize_dir, resource) + if not os.path.isfile(path): + continue + try: + with open(path) as f: + content = f.read() + except Exception: + continue + if "127.0.0.11" in content: + add_error(errors, ignored_rules, "WC-DKRDNS", + f"{os.path.basename(path)}: contains Docker DNS resolver (127.0.0.11)" + " — use CoreDNS instead (see note #24)") + + +def check_resource_parse_errors(kustomize_dir, resources, errors, warnings, ignored_rules): + """Check that all listed resource files parse as valid YAML (WC-RES-PARSE).""" + for resource in resources: + path = os.path.join(kustomize_dir, resource) + if not os.path.isfile(path): + continue # WC-RES already handles missing files + try: + list(load_yaml_all(path)) + except Exception as e: + add_error(errors, ignored_rules, "WC-RES-PARSE", + f"{resource}: YAML parse error: {e}") + + +def check_namespace_resource(kustomize_dir, resources, errors, warnings, ignored_rules): + """Check that a Namespace resource exists when workloads are present (WC-NSFILE).""" + if not resources_have_workloads(kustomize_dir, resources): + return + for _, doc in resource_docs(kustomize_dir, resources): + if doc.get("kind") == "Namespace": + return + add_warning(warnings, ignored_rules, "WC-NSFILE", + "no Namespace resource in kustomization.yaml — add namespace.yaml") + + +def check_template_vars(kustomize_dir, default_config, errors, warnings, ignored_rules): + """Check top-level template variables in resource files against defaultConfig (WC-TVAR).""" + if not default_config: + return + defined_keys = set(default_config.keys()) + for fname in sorted(os.listdir(kustomize_dir)): + if not fname.endswith(".yaml") or fname == "manifest.yaml": + continue + path = os.path.join(kustomize_dir, fname) + try: + with open(path) as f: + content = f.read() + except Exception: + continue + seen_undefined = set() + for block in re.finditer(r"\{\{-?\s*(.*?)\s*-?\}\}", content, re.DOTALL): + for m in re.finditer(r"(?. format + for rs in manifest.get("requiredSecrets") or []: + if isinstance(rs, str) and "." not in rs: + add_error(errors, ignored_rules, "WC-DOT", + f"manifest.yaml: requiredSecret {rs!r} missing dot notation (.)") + + +def validate_version(version_dir, app_name, errors, warnings, ignored_rules): + validate_manifest(version_dir, errors, warnings, ignored_rules) + + # Load defaultConfig for template variable checking + default_config = {} + manifest_path = os.path.join(version_dir, "manifest.yaml") + if os.path.exists(manifest_path): + try: + manifest_data = load_yaml(manifest_path) or {} + default_config = manifest_data.get("defaultConfig") or {} + except Exception: + pass + + # Config-only apps (e.g. smtp) have no kubernetes resources — skip kustomization check + kustomize_path = os.path.join(version_dir, "kustomization.yaml") + resource_yamls = [ + f for f in os.listdir(version_dir) + if f.endswith(".yaml") and f not in ("manifest.yaml", "kustomization.yaml") + ] + if os.path.exists(kustomize_path) or resource_yamls: + validate_kustomization(version_dir, app_name, default_config, errors, warnings, ignored_rules) + + +def validate_app(app_dir): + errors = [] + warnings = [] + app_name = os.path.basename(app_dir) + + app_yaml_path = os.path.join(app_dir, "app.yaml") + if not os.path.exists(app_yaml_path): + return [("WC-APP-MISS", "MISSING app.yaml")], [] + + try: + app_data = load_yaml(app_yaml_path) + except Exception as e: + return [("WC-APP-PARSE", f"PARSE ERROR in app.yaml: {e}")], [] + + if not app_data: + return [("WC-APP-EMPTY", "EMPTY app.yaml")], [] + + ignored_rules = set(app_data.get("ignoreRules") or []) + + # Flag any ignoreRules entries that don't correspond to a known rule + for rule_id in ignored_rules: + if rule_id not in RULES: + errors.append(("WC-IGN", f"app.yaml ignoreRules contains unknown rule: {rule_id!r}")) + + for field in REQUIRED_APP_YAML_FIELDS: + if not app_data.get(field): + add_error(errors, ignored_rules, "WC-REQ", + f"app.yaml missing required field: {field}") + + # icon is cosmetic — warn rather than error + if not app_data.get("icon"): + add_warning(warnings, ignored_rules, "WC-ICON", "app.yaml missing field: icon") + + # name must match directory name + if app_data.get("name") and app_data["name"] != app_name: + add_error(errors, ignored_rules, "WC-NAME", + f"app.yaml name {app_data['name']!r} doesn't match directory name {app_name!r}") + + latest = str(app_data.get("latest", "")) + versions_dir = os.path.join(app_dir, "versions") + + if not os.path.isdir(versions_dir): + add_error(errors, ignored_rules, "WC-VER", "MISSING versions/ directory") + return errors, warnings + + # Check slot naming convention and validate each slot + for slot_name in sorted(os.listdir(versions_dir)): + slot_path = os.path.join(versions_dir, slot_name) + if not os.path.isdir(slot_path): + continue + # Flag full version strings (two or more dots) or packaging revision suffix (-N) + if re.search(r"\.\d+\.\d+", slot_name) or re.search(r"-\d+$", slot_name): + add_warning(warnings, ignored_rules, "WC-SLOT", + f"versions/{slot_name}: slot name looks like a full version or revision" + " — use the major version only (e.g. '1', 'v1')") + + if latest: + latest_dir = os.path.join(versions_dir, latest) + if not os.path.isdir(latest_dir): + add_error(errors, ignored_rules, "WC-LATEST", + f"latest version directory not found: versions/{latest}") + else: + ver_errors, ver_warnings = [], [] + validate_version(latest_dir, app_name, ver_errors, ver_warnings, ignored_rules) + errors.extend((rule_id, f"versions/{latest}: {msg}") for rule_id, msg in ver_errors) + warnings.extend((rule_id, f"versions/{latest}: {msg}") for rule_id, msg in ver_warnings) + + # Check that upgrade waypoint slots exist + upgrade = app_data.get("upgrade") or {} + for rule in upgrade.get("from") or []: + via = rule.get("via") + if via: + via_dir = os.path.join(versions_dir, str(via)) + if not os.path.isdir(via_dir): + add_error(errors, ignored_rules, "WC-VIA", + f"app.yaml upgrade.from[].via={via!r} references a non-existent slot") + + return errors, warnings + + +def main(): + args = sys.argv[1:] + + if "--list-rules" in args: + print("Rule IDs for use in app.yaml ignoreRules:\n") + for rule_id in RULES: + sev = SEVERITY.get(rule_id, "warning") + print(f" {rule_id:<14} {sev:<8} {RULES[rule_id]}") + if rule_id in REMEDIATION: + lines = REMEDIATION[rule_id].split("\n") + print(f" {'':<14} Fix: {lines[0]}") + for line in lines[1:]: + print(f" {'':<14} {line}") + return 0 + + requested = [a for a in args if not a.startswith("--")] + + wild_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + if requested: + app_dirs = [] + for name in requested: + path = os.path.join(wild_dir, name) + if not os.path.isdir(path): + print(f"ERROR: {name} not found in wild-directory", file=sys.stderr) + sys.exit(1) + app_dirs.append(path) + else: + app_dirs = sorted( + os.path.join(wild_dir, d) + for d in os.listdir(wild_dir) + if os.path.isdir(os.path.join(wild_dir, d)) + and not d.startswith(".") + and os.path.exists(os.path.join(wild_dir, d, "app.yaml")) + ) + + total_errors = 0 + total_warnings = 0 + + for app_dir in app_dirs: + app_name = os.path.basename(app_dir) + errors, warnings = validate_app(app_dir) + + if errors or warnings: + print(f"\n{app_name}:") + shown_remediation = set() + for rule_id, msg in errors: + print(f" ERROR [{rule_id}] {msg}") + if rule_id in REMEDIATION and rule_id not in shown_remediation: + lines = REMEDIATION[rule_id].split("\n") + print(f" Fix: {lines[0]}") + for line in lines[1:]: + print(f" {line}") + shown_remediation.add(rule_id) + for rule_id, msg in warnings: + print(f" WARN [{rule_id}] {msg}") + if rule_id in REMEDIATION and rule_id not in shown_remediation: + lines = REMEDIATION[rule_id].split("\n") + print(f" Fix: {lines[0]}") + for line in lines[1:]: + print(f" {line}") + shown_remediation.add(rule_id) + + total_errors += len(errors) + total_warnings += len(warnings) + + print(f"\n{'─'*50}") + print(f"checked {len(app_dirs)} apps • {total_errors} errors • {total_warnings} warnings") + + return 1 if total_errors > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/admin/scripts/validation-rules.yaml b/admin/scripts/validation-rules.yaml new file mode 100644 index 0000000..988eae5 --- /dev/null +++ b/admin/scripts/validation-rules.yaml @@ -0,0 +1,284 @@ +# Wild Cloud App Validation Rules +# +# Loaded by validate-apps.py at runtime. +# Run `validate-apps.py --list-rules` for a formatted summary. +# +# To suppress a rule for an app, add its ID to ignoreRules in app.yaml: +# ignoreRules: +# - WC-HELM # upstream Helm-rendered manifests +# +# Fields: +# severity: error (must fix before submitting) | warning (strong suggestion) +# description: What the rule checks for +# remediation: How to fix the issue + +# ── app.yaml ────────────────────────────────────────────────────────────────── + +WC-APP-MISS: + severity: error + description: app.yaml file is missing + remediation: Create app.yaml with name, description, category, latest, and icon fields + +WC-IGN: + severity: error + description: app.yaml ignoreRules contains an unknown rule ID + remediation: Remove the stale entry from ignoreRules, or check for a typo in the rule ID (run --list-rules to see valid IDs) + +WC-APP-PARSE: + severity: error + description: app.yaml could not be parsed as valid YAML + remediation: Fix YAML syntax errors in app.yaml + +WC-APP-EMPTY: + severity: error + description: app.yaml is empty + remediation: Populate app.yaml — it is empty + +WC-REQ: + severity: error + description: app.yaml missing one or more required fields + remediation: "Add the missing fields: name, is, description, category, and latest are all required" + +WC-NAME: + severity: error + description: app.yaml name does not match the directory name + remediation: "Set 'name:' in app.yaml to exactly match the directory name" + +WC-ICON: + severity: warning + description: app.yaml missing icon field + remediation: "Add 'icon: ' to app.yaml pointing to the app's logo" + +# ── versions ────────────────────────────────────────────────────────────────── + +WC-VER: + severity: error + description: versions/ directory is missing + remediation: Create a versions/ subdirectory with at least one version slot directory inside it + +WC-LATEST: + severity: error + description: latest version directory does not exist + remediation: "Create the version slot directory named in 'latest:', or update 'latest:' to an existing slot" + +WC-VIA: + severity: error + description: upgrade.from[].via references a slot directory that does not exist + remediation: "Create the waypoint slot directory, or fix the via: value to match an existing slot" + +WC-SLOT: + severity: warning + description: version slot directory name looks like a full version string or packaging revision + remediation: | + Rename the slot to the major version only (e.g. '1', 'v1', 'v0'). + Full versions ('1.2.3') and packaging revisions ('1-2') do not belong in directory names. + The actual version string lives in manifest.yaml. + +# ── manifest.yaml ───────────────────────────────────────────────────────────── + +WC-NSFILE: + severity: warning + description: no Namespace resource defined — workloads will deploy into an unmanaged namespace + remediation: | + Add a namespace.yaml listing it in kustomization.yaml resources: + apiVersion: v1 + kind: Namespace + metadata: + name: {{ .namespace }} + +# ── manifest.yaml ───────────────────────────────────────────────────────────── + +WC-MAN-MISS: + severity: error + description: manifest.yaml is missing + remediation: "Create manifest.yaml with at minimum: version, defaultConfig.namespace" + +WC-MAN-PARSE: + severity: error + description: manifest.yaml could not be parsed + remediation: Fix YAML syntax errors in manifest.yaml + +WC-MAN-EMPTY: + severity: error + description: manifest.yaml is empty + remediation: Populate manifest.yaml — it is empty + +WC-MAN-REQ: + severity: error + description: manifest.yaml missing a required field + remediation: "Add the missing field to manifest.yaml (at minimum: version)" + +WC-TVAR: + severity: warning + description: template variable in resource file not found in defaultConfig + remediation: | + Add the missing variable to defaultConfig in manifest.yaml, or check for a typo in the template. + Only top-level variable names are checked — e.g. .db in {{ .db.host }} must appear as a key under defaultConfig. + Global namespaces (cloud, cluster, operator, apps, secrets, app) are always available and exempt. + +WC-CFG: + severity: warning + description: manifest.yaml defaultConfig is empty or missing + remediation: "Add a 'defaultConfig:' block to manifest.yaml" + +WC-CFG-NS: + severity: warning + description: manifest.yaml defaultConfig missing namespace field + remediation: "Add 'namespace: ' under defaultConfig in manifest.yaml" + +WC-SSL: + severity: error + description: postgres URL missing ?sslmode=disable + remediation: | + Append ?sslmode=disable to the postgres connection URL: + postgresql://user:pass@host:5432/db?sslmode=disable + +WC-DOT: + severity: error + description: requiredSecret does not use . dot notation + remediation: "Use . format in requiredSecrets (e.g. postgres.password, not just 'password')" + +# ── kustomization.yaml ──────────────────────────────────────────────────────── + +WC-KUS-MISS: + severity: error + description: kustomization.yaml is missing + remediation: Create kustomization.yaml listing all resource files with Wild Cloud labels + +WC-KUS-PARSE: + severity: error + description: kustomization.yaml could not be parsed + remediation: Fix YAML syntax errors in kustomization.yaml + +WC-KUS-EMPTY: + severity: error + description: kustomization.yaml is empty + remediation: "Add a resources: list and labels: block to kustomization.yaml" + +WC-RES: + severity: error + description: a resource listed in kustomization.yaml does not exist on disk + remediation: "Create the missing file, or remove it from resources: in kustomization.yaml" + +WC-RES-PARSE: + severity: error + description: a resource file listed in kustomization.yaml could not be parsed as valid YAML + remediation: Fix YAML syntax errors in the resource file + +WC-NS: + severity: warning + description: no namespace in kustomization.yaml (has Deployment/StatefulSet) + remediation: "Add 'namespace: {{ .namespace }}' to kustomization.yaml" + +WC-SEL: + severity: error + description: labels.includeSelectors true missing (required for Deployment/StatefulSet) + remediation: | + Add to kustomization.yaml: + labels: + - includeSelectors: true + pairs: + app: + managedBy: kustomize + partOf: wild-cloud + +WC-PAIRS: + severity: warning + description: a standard label pair is missing or has the wrong value + remediation: "Ensure labels.pairs includes managedBy: kustomize and partOf: wild-cloud" + +WC-LBL: + severity: warning + description: label pair 'app' does not match the app directory name + remediation: "Set 'app:' in label pairs to match the app directory name" + +# ── workloads ───────────────────────────────────────────────────────────────── + +WC-SC-POD: + severity: warning + description: no pod-level securityContext defined + remediation: | + Add to the pod spec: + securityContext: + runAsNonRoot: true + runAsUser: + seccompProfile: + type: RuntimeDefault + +WC-SC-CTR: + severity: warning + description: no container-level securityContext defined + remediation: | + Add to each container spec: + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + +WC-RWO: + severity: error + description: Deployment using a ReadWriteOnce PVC must use strategy Recreate + remediation: "Set strategy: type: Recreate on the Deployment — RWO volumes cannot attach to two pods at once" + +WC-HELM: + severity: error + description: Helm-style app.kubernetes.io/* labels found in pod template + remediation: "Remove app.kubernetes.io/* labels from pod template; use simple labels (e.g. component: web) instead" + +WC-IMG: + severity: error + description: container image uses a mutable or missing version tag + remediation: "Pin the image to a specific release tag (e.g. myimage:1.2.3), not latest/main/stable/etc." + +WC-BITNAMI: + severity: warning + description: container image is from Bitnami — prefer an official image + remediation: | + Replace the Bitnami image with the official upstream image. + Bitnami images require Docker Hub authentication since 2023, use non-standard filesystem layouts, + and are often a sign of a Helm chart that wasn't fully adapted. Check Docker Hub or the project's + docs for the official image (e.g. postgres:15, redis:alpine, mariadb:11). + +# ── ingress ─────────────────────────────────────────────────────────────────── + +WC-ING-ANN: + severity: error + description: ingress uses deprecated kubernetes.io/ingress.class annotation + remediation: "Replace the annotation with spec.ingressClassName: traefik in the Ingress spec" + +WC-ING-CLS: + severity: warning + description: ingress spec.ingressClassName should be traefik + remediation: "Add 'ingressClassName: traefik' under spec: in the Ingress" + +WC-CERT: + severity: error + description: ingress uses cert-manager annotation (wildcard cert is pre-distributed) + remediation: "Remove the cert-manager.io/cluster-issuer annotation — wildcard TLS is pre-distributed to namespaces" + +WC-DNS: + severity: warning + description: ingress missing external-dns.alpha.kubernetes.io/target annotation + remediation: | + Add to Ingress metadata.annotations: + external-dns.alpha.kubernetes.io/target: '{{ .externalDnsDomain }}' + +# ── db-init ─────────────────────────────────────────────────────────────────── + +WC-DKRDNS: + severity: error + description: Docker DNS resolver (127.0.0.11) used — not available in Kubernetes + remediation: | + Remove `resolver 127.0.0.11` and avoid nginx variables in proxy_pass. + Use `proxy_pass http://backend-name:port;` (no variable) so nginx resolves via CoreDNS at startup. + If a runtime-resolved variable is unavoidable, use the cluster DNS resolver instead: + resolver kube-dns.kube-system.svc.cluster.local valid=30s; + See docs/nginx.md for the full pattern. + +WC-DBIN: + severity: error + description: MySQL db-init uses CREATE USER without ALTER USER (passwords won't update on redeploy) + remediation: | + Add ALTER USER after CREATE USER so passwords update on redeploy: + CREATE USER IF NOT EXISTS '${USER}'@'%' IDENTIFIED BY '${PASSWORD}'; + ALTER USER '${USER}'@'%' IDENTIFIED BY '${PASSWORD}';