diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 00000000..eb0a4809 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,36 @@ +--- +name: Upstream contracts + +# The offline half of this runs in every pull request via code_quality.bats, against the snapshots +# in tests/contracts/. This is the half that needs the network: it re-fetches each contract at the +# ref this installer pins, so a snapshot that has drifted is caught, and reports a pin that has +# fallen behind a release - which is how an install sat on a hivemind-docker tag for weeks after +# the fix it needed had shipped. + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: upstream-contracts + cancel-in-progress: false + +jobs: + contracts: + name: Verify pinned contracts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - run: pip install --disable-pip-version-check pyyaml + + - name: Check the installer against the contracts at the pinned refs + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/check_contracts.py --online --fail-on-stale diff --git a/scripts/check_contracts.py b/scripts/check_contracts.py new file mode 100755 index 00000000..f0d1cff1 --- /dev/null +++ b/scripts/check_contracts.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Check this installer against the contracts published by the repositories it clones. + +The containers method does not build anything: it clones ovos-docker and hivemind-docker at a +pinned ref and runs the compose files out of them. Four things are therefore an interface between +those repositories and this one, and all four have broken an install: + + compose file names named exactly here; a rename upstream fails the install + container names docker_container_exec targets them by name; a rename fails late, after the + stack is already up and pulled + environment every variable the compose reads must come from env.j2 - or, for a variable + with an inline default, must come from env.j2 anyway when the installer is + the one that owns the value + images the compose is pinned by tag, the images move with a channel tag, so an + image can be older than the compose that expects it + +The third is the one that stays quiet. A compose file that starts reading HIVEMIND_SITEID keeps +working: it just uses the literal string "default" instead of the site id collected here. Nothing +fails, and every satellite reports the wrong site. That is why a contract marks such a variable +`owner: installer` and why this refuses to treat "has a default" as "nobody needs to set it". + +Offline by default, against the snapshots in tests/contracts/, so this runs in any pull request. +--online re-fetches them at the pinned refs, reports a pin that has fallen behind a release, and +checks that the published images are not older than the compose pinned here. +""" +import argparse +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +CONTAINERS = ROOT / "ansible/roles/ovos_containers/defaults/main.yml" +INSTALLER = ROOT / "ansible/roles/ovos_installer/defaults/main.yml" +ENV_TEMPLATE = ROOT / "ansible/roles/ovos_containers/templates/docker/env.j2" +SNAPSHOTS = ROOT / "tests/contracts" + +# The satellite profile is the only one that runs out of hivemind-docker; everything else comes +# from ovos-docker. ovos_containers_composition_directory switches on exactly that condition. +HIVEMIND_COMPOSE_VARS = {"ovos_containers_compose_file_satellite"} + +REPOS = { + "ovos-docker": "JarbasHiveMind/ovos-docker", # corrected below from the installer defaults + "hivemind-docker": "JarbasHiveMind/hivemind-docker", +} + + +def installer_facts() -> dict: + containers = CONTAINERS.read_text() + installer = INSTALLER.read_text() + + compose = dict(re.findall(r"^(ovos_containers_compose_file_[a-z_]+):\s*(\S+)$", containers, re.M)) + containers_named = dict(re.findall(r"^(ovos_containers_container_[a-z_]+):\s*(\S+)$", containers, re.M)) + pins = dict(re.findall(r"^ovos_installer_(ovos|hivemind)_docker_repo_branch:\s*(\S+)$", installer, re.M)) + urls = dict(re.findall(r"^ovos_installer_(ovos|hivemind)_docker_repo_url:\s*(\S+)$", installer, re.M)) + + files = {"hivemind-docker": set(), "ovos-docker": set()} + for var, name in compose.items(): + target = "hivemind-docker" if var in HIVEMIND_COMPOSE_VARS else "ovos-docker" + files[target].add(name) + + return { + "compose_files": files, + "container_names": set(containers_named.values()), + "provided_env": set(re.findall(r"^([A-Z][A-Z0-9_]*)=", ENV_TEMPLATE.read_text(), re.M)), + "pins": {"ovos-docker": pins.get("ovos"), "hivemind-docker": pins.get("hivemind")}, + "slugs": {k: re.sub(r"^https://github\.com/|\.git$", "", v) + for k, v in {"ovos-docker": urls.get("ovos", ""), + "hivemind-docker": urls.get("hivemind", "")}.items()}, + } + + +def check(repo: str, contract: dict, facts: dict) -> list: + problems = [] + wanted_files = facts["compose_files"][repo] + declared_files = set(contract.get("compose_files") or []) + + for name in sorted(wanted_files - declared_files): + problems.append(f"{repo}: compose file {name} is used here but not declared by the contract") + + # Only the files this installer actually runs matter; a satellite install must not be asked + # for variables that only the matrix-bot compose reads. + used = wanted_files & declared_files + + declared_containers = { + name for compose_file, services in (contract.get("services") or {}).items() + if compose_file in used for name in services.values() + } + all_containers = { + name for services in (contract.get("services") or {}).values() for name in services.values() + } + for name in sorted(facts["container_names"] & all_containers - declared_containers): + problems.append(f"{repo}: container {name} is referenced here but is not in any compose " + f"file this installer runs") + + for variable, spec in sorted((contract.get("env") or {}).items()): + if not (set(spec.get("compose_files") or []) & used): + continue + if variable in facts["provided_env"]: + continue + if spec.get("required"): + problems.append(f"{repo}: {variable} is required by {', '.join(sorted(set(spec['compose_files']) & used))} " + f"and is not set in env.j2") + elif spec.get("owner") == "installer": + problems.append(f"{repo}: {variable} is owned by the installer but is not set in env.j2 - " + f"the compose has a default, so this fails silently rather than loudly") + return problems + + +def fetch(slug: str, ref: str, path: str) -> str | None: + url = f"https://raw.githubusercontent.com/{slug}/{ref}/{path}" + try: + with urllib.request.urlopen(url, timeout=30) as response: + return response.read().decode() + except Exception: + return None + + +def release_lag(slug: str, tag: str) -> tuple[int, str] | None: + """How far the newest release trails the branch it is cut from. + + Pinning a release is what makes an install reproducible, but a fix only reaches anyone once a + release carries it. Comparing the pin against the newest release cannot see this: both can say + v2.0.2 while ten days of fixes sit unreleased on the default branch, which is exactly how the + fann2 gating and the intent-engine assertion failed to reach a single install. + """ + try: + branch = subprocess.run(["gh", "repo", "view", slug, "--json", "defaultBranchRef", + "-q", ".defaultBranchRef.name"], + capture_output=True, text=True, check=True).stdout.strip() + out = subprocess.run(["gh", "api", f"repos/{slug}/compare/{tag}...{branch}", + "-q", ".ahead_by"], capture_output=True, text=True, check=True) + return int(out.stdout.strip()), branch + except Exception: + return None + + +def latest_release(slug: str) -> str | None: + try: + out = subprocess.run(["gh", "release", "view", "--repo", slug, "--json", "tagName", "-q", ".tagName"], + capture_output=True, text=True, check=True) + return out.stdout.strip() or None + except Exception: + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--online", action="store_true", + help="re-fetch the contracts at the pinned refs and check pin freshness") + parser.add_argument("--fail-on-stale", action="store_true", + help="treat a pin behind the latest release as a failure. A scheduled run " + "that exits 0 tells nobody anything, so the job that watches for " + "this uses it; the pull request check does not.") + args = parser.parse_args() + + facts = installer_facts() + problems, notes = [], [] + + for repo in ("ovos-docker", "hivemind-docker"): + snapshot = SNAPSHOTS / f"{repo}.yml" + if not snapshot.exists(): + problems.append(f"{repo}: no contract snapshot at {snapshot.relative_to(ROOT)}") + continue + contract = yaml.safe_load(snapshot.read_text()) + + if args.online: + slug, ref = facts["slugs"][repo], facts["pins"][repo] + live = fetch(slug, ref, "contract.yml") + if live is None: + notes.append(f"{repo}: {ref} publishes no contract.yml yet; using the snapshot") + elif yaml.safe_load(live) != contract: + problems.append(f"{repo}: the snapshot differs from contract.yml at {ref} - " + f"refresh tests/contracts/{repo}.yml") + newest = latest_release(slug) + if newest and newest != ref: + message = f"{repo}: pinned {ref}, latest release is {newest}" + (problems if args.fail_on_stale else notes).append(message) + + lag = release_lag(slug, newest or ref) + if lag and lag[0]: + behind, branch = lag + message = (f"{repo}: {newest or ref} is {behind} commit(s) behind {branch} - " + f"those fixes are in no release, so no install has them") + (problems if args.fail_on_stale else notes).append(message) + + problems.extend(check(repo, contract, facts)) + + for note in notes: + print(f"note: {note}") + sys.stdout.flush() # keep notes above the violations in a CI log + + if problems: + print("\ncontract violations:", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + return 1 + + print(f"contracts satisfied: {', '.join(f'{r} {facts['pins'][r]}' for r in facts['pins'])}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/bats/code_quality.bats b/tests/bats/code_quality.bats index f22ede54..7ccd65c6 100644 --- a/tests/bats/code_quality.bats +++ b/tests/bats/code_quality.bats @@ -3732,3 +3732,19 @@ function teardown() { run grep -qE "^HIVEMIND_SITEID=\{\{ env\(ovos_installer_site_id" "$env_template" assert_success } + +@test "installer_satisfies_the_contracts_of_the_repositories_it_clones" { + # The containers method builds nothing: it clones ovos-docker and hivemind-docker at a + # pinned ref and runs their compose files. The compose file names, the container names + # docker_container_exec targets, and the variables the compose reads are therefore an + # interface, and each has broken an install. The quietest is a variable with an inline + # default that the installer is nonetheless meant to own - HIVEMIND_SITEID kept working + # and silently reported the wrong site id - so the contracts mark those `owner: installer` + # and this refuses to read "has a default" as "nobody needs to set it". + run python3 scripts/check_contracts.py + if [ "$status" -ne 0 ]; then + echo "$output" >&2 + echo "refresh tests/contracts/ or fix env.j2; see scripts/check_contracts.py" >&2 + fi + assert_success +} diff --git a/tests/contracts/hivemind-docker.yml b/tests/contracts/hivemind-docker.yml new file mode 100644 index 00000000..d9ea3c5c --- /dev/null +++ b/tests/contracts/hivemind-docker.yml @@ -0,0 +1,148 @@ +--- +# Generated by scripts/contract.py - run it after changing compose/. +# What ovos-installer may rely on from this repository. `owner` and `description` +# are hand-written and preserved; everything else is derived from the compose files. +version: 1 +compose_files: + - docker-compose.chatroom.yml + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + - docker-compose.webchat.yml + - docker-compose.yml +services: + docker-compose.chatroom.yml: + hivemind_chatroom: hivemind_chatroom + docker-compose.matrix-bot.yml: + hivemind_matrix_bot: hivemind_matrix_bot + docker-compose.satellite.yml: + hivemind_cli: hivemind_cli + hivemind_satellite: hivemind_satellite + docker-compose.webchat.yml: + hivemind_webchat: hivemind_webchat + docker-compose.yml: + hivemind_cli: hivemind_cli + hivemind_listener: hivemind_listener +env: + HIVEMIND_CONFIG_FOLDER: + required: true + compose_files: + - docker-compose.satellite.yml + - docker-compose.yml + HIVEMIND_SHARE_FOLDER: + required: true + compose_files: + - docker-compose.satellite.yml + - docker-compose.yml + HIVEMIND_SITEID: + required: false + compose_files: + - docker-compose.satellite.yml + owner: installer + description: Site id reported in message.context. The compose falls back to the + literal "default", so an installer that does not set it produces a working satellite + reporting the wrong site - silently. Not optional for a consumer that collects + a site id. + HIVEMIND_USER: + required: true + compose_files: + - docker-compose.satellite.yml + - docker-compose.yml + LIGHT_MEMORY_LIMIT: + required: false + compose_files: + - docker-compose.chatroom.yml + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + - docker-compose.webchat.yml + - docker-compose.yml + MATRIX_BOT_NAME: + required: true + compose_files: + - docker-compose.matrix-bot.yml + MATRIX_HOST: + required: true + compose_files: + - docker-compose.matrix-bot.yml + MATRIX_ROOM: + required: true + compose_files: + - docker-compose.matrix-bot.yml + MATRIX_TOKEN: + required: true + compose_files: + - docker-compose.matrix-bot.yml + OVOS_CONFIG_FOLDER: + required: true + compose_files: + - docker-compose.satellite.yml + SAT_HOST: + required: true + compose_files: + - docker-compose.chatroom.yml + SAT_KEY: + required: true + compose_files: + - docker-compose.chatroom.yml + SAT_PASSWORD: + required: true + compose_files: + - docker-compose.chatroom.yml + SAT_PORT: + required: true + compose_files: + - docker-compose.chatroom.yml + STANDARD_MEMORY_LIMIT: + required: false + compose_files: + - docker-compose.chatroom.yml + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + - docker-compose.webchat.yml + - docker-compose.yml + TZ: + required: true + compose_files: + - docker-compose.chatroom.yml + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + - docker-compose.webchat.yml + - docker-compose.yml + VERSION: + required: true + compose_files: + - docker-compose.chatroom.yml + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + - docker-compose.webchat.yml + - docker-compose.yml + VOICE_SAT_HOST: + required: true + compose_files: + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + VOICE_SAT_KEY: + required: true + compose_files: + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + VOICE_SAT_PASSWORD: + required: true + compose_files: + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + VOICE_SAT_PORT: + required: true + compose_files: + - docker-compose.matrix-bot.yml + - docker-compose.satellite.yml + XDG_RUNTIME_DIR: + required: true + compose_files: + - docker-compose.satellite.yml +images: + - docker.io/smartgic/hivemind-chatroom + - docker.io/smartgic/hivemind-cli + - docker.io/smartgic/hivemind-listener + - docker.io/smartgic/hivemind-matrix-bot + - docker.io/smartgic/hivemind-satellite + - docker.io/smartgic/hivemind-webchat diff --git a/tests/contracts/ovos-docker.yml b/tests/contracts/ovos-docker.yml new file mode 100644 index 00000000..119aabec --- /dev/null +++ b/tests/contracts/ovos-docker.yml @@ -0,0 +1,298 @@ +--- +# Generated by scripts/contract.py - run it after changing compose/. +# What ovos-installer may rely on from this repository. `owner` and `description` +# are hand-written and preserved; everything else is derived from the compose files. +version: 1 +compose_files: + - docker-compose.gui.yml + - docker-compose.hivemind.yml + - docker-compose.macos.yml + - docker-compose.raspberrypi.gui.yml + - docker-compose.raspberrypi.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml +services: + docker-compose.gui.yml: + ovos_gui: ovos_gui + ovos_gui_websocket: ovos_gui_websocket + ovos_skill_homescreen: ovos_skill_homescreen + docker-compose.hivemind.yml: + hivemind_cli: hivemind_cli + hivemind_listener: hivemind_listener + docker-compose.macos.yml: + ovos_audio: ovos_audio + ovos_cli: ovos_cli + ovos_core: ovos_core + ovos_listener: ovos_listener + ovos_messagebus: ovos_messagebus + ovos_phal: ovos_phal + ovos_phal_admin: ovos_phal_admin + docker-compose.server.yml: + hivemind_cli: hivemind_cli + hivemind_listener: hivemind_listener + ovos_core: ovos_core + ovos_messagebus: ovos_messagebus + docker-compose.skills-extra.yml: + ovos_skill_camera: ovos_skill_camera + ovos_skill_easter_eggs: ovos_skill_easter_eggs + ovos_skill_homeassistant: ovos_skill_homeassistant + ovos_skill_jokes: ovos_skill_jokes + ovos_skill_parrot: ovos_skill_parrot + ovos_skill_randomness: ovos_skill_randomness + ovos_skill_wikihow: ovos_skill_wikihow + ovos_skill_wolfie: ovos_skill_wolfie + docker-compose.skills.yml: + ovos_skill_alerts: ovos_skill_alerts + ovos_skill_date_time: ovos_skill_date_time + ovos_skill_duckduckgo: ovos_skill_duckduckgo + ovos_skill_fallback_unknown: ovos_skill_fallback_unknown + ovos_skill_ggwave: ovos_skill_ggwave + ovos_skill_hello_world: ovos_skill_hello_world + ovos_skill_personal: ovos_skill_personal + ovos_skill_volume: ovos_skill_volume + ovos_skill_weather: ovos_skill_weather + ovos_skill_wikipedia: ovos_skill_wikipedia + ovos_skill_wordnet: ovos_skill_wordnet + docker-compose.windows.yml: + ovos_audio: ovos_audio + ovos_cli: ovos_cli + ovos_core: ovos_core + ovos_listener: ovos_listener + ovos_messagebus: ovos_messagebus + ovos_phal: ovos_phal + ovos_phal_admin: ovos_phal_admin + docker-compose.yml: + ovos_audio: ovos_audio + ovos_cli: ovos_cli + ovos_core: ovos_core + ovos_listener: ovos_listener + ovos_messagebus: ovos_messagebus + ovos_phal: ovos_phal + ovos_phal_admin: ovos_phal_admin +env: + CORE_MEMORY_LIMIT: + required: false + compose_files: + - docker-compose.macos.yml + - docker-compose.raspberrypi.yml + - docker-compose.server.yml + - docker-compose.windows.yml + - docker-compose.yml + DISPLAY: + required: true + compose_files: + - docker-compose.gui.yml + GPIO_GID: + required: true + compose_files: + - docker-compose.raspberrypi.yml + GUI_MEMORY_LIMIT: + required: false + compose_files: + - docker-compose.gui.yml + HIVEMIND_CONFIG_FOLDER: + required: true + compose_files: + - docker-compose.hivemind.yml + - docker-compose.server.yml + HIVEMIND_SHARE_FOLDER: + required: true + compose_files: + - docker-compose.hivemind.yml + - docker-compose.server.yml + HIVEMIND_USER: + required: true + compose_files: + - docker-compose.hivemind.yml + - docker-compose.server.yml + I2C_GID: + required: true + compose_files: + - docker-compose.raspberrypi.yml + INPUT_GID: + required: true + compose_files: + - docker-compose.gui.yml + LIGHT_MEMORY_LIMIT: + required: false + compose_files: + - docker-compose.gui.yml + - docker-compose.hivemind.yml + - docker-compose.raspberrypi.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.yml + OVOS_CONFIG_FOLDER: + required: true + compose_files: + - docker-compose.gui.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml + OVOS_CONFIG_PHAL_FOLDER: + required: true + compose_files: + - docker-compose.yml + OVOS_PERSONA_FOLDER: + required: true + compose_files: + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.windows.yml + - docker-compose.yml + OVOS_SHARE_FOLDER: + required: true + compose_files: + - docker-compose.gui.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.windows.yml + - docker-compose.yml + OVOS_USER: + required: true + compose_files: + - docker-compose.gui.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml + PULL_POLICY: + required: false + compose_files: + - docker-compose.gui.yml + - docker-compose.hivemind.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml + owner: installer + description: Image pull policy. The compose has a fallback, so an unset value + silently overrides the policy the installer was configured with rather than + failing. + PULSE_SERVER: + required: false + compose_files: + - docker-compose.macos.yml + - docker-compose.windows.yml + QT_QPA_EGLFS_INTEGRATION: + required: true + compose_files: + - docker-compose.gui.yml + QT_QPA_EGLFS_KMS_CONFIG: + required: true + compose_files: + - docker-compose.gui.yml + QT_QPA_PLATFORM: + required: true + compose_files: + - docker-compose.gui.yml + RENDER_GID: + required: true + compose_files: + - docker-compose.gui.yml + SPI_GID: + required: true + compose_files: + - docker-compose.raspberrypi.yml + STANDARD_MEMORY_LIMIT: + required: false + compose_files: + - docker-compose.gui.yml + - docker-compose.macos.yml + - docker-compose.raspberrypi.yml + - docker-compose.server.yml + - docker-compose.windows.yml + - docker-compose.yml + TMP_FOLDER: + required: true + compose_files: + - docker-compose.gui.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml + TZ: + required: false + compose_files: + - docker-compose.gui.yml + - docker-compose.hivemind.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml + owner: installer + description: Timezone for every service. The compose falls back to a default, + so an installer that does not set it produces a working stack in the wrong timezone + - silently. + VERSION: + required: true + compose_files: + - docker-compose.gui.yml + - docker-compose.hivemind.yml + - docker-compose.macos.yml + - docker-compose.server.yml + - docker-compose.skills-extra.yml + - docker-compose.skills.yml + - docker-compose.windows.yml + - docker-compose.yml + VIDEO_GID: + required: true + compose_files: + - docker-compose.gui.yml + WAYLAND_DISPLAY: + required: true + compose_files: + - docker-compose.gui.yml + XDG_RUNTIME_DIR: + required: true + compose_files: + - docker-compose.gui.yml + - docker-compose.windows.yml + - docker-compose.yml +images: + - docker.io/smartgic/hivemind-cli + - docker.io/smartgic/hivemind-listener + - docker.io/smartgic/ovos-audio + - docker.io/smartgic/ovos-cli + - docker.io/smartgic/ovos-core + - docker.io/smartgic/ovos-gui-shell + - docker.io/smartgic/ovos-gui-websocket + - docker.io/smartgic/ovos-listener + - docker.io/smartgic/ovos-messagebus + - docker.io/smartgic/ovos-phal + - docker.io/smartgic/ovos-phal-admin + - docker.io/smartgic/ovos-skill-alerts + - docker.io/smartgic/ovos-skill-camera + - docker.io/smartgic/ovos-skill-date-time + - docker.io/smartgic/ovos-skill-duckduckgo + - docker.io/smartgic/ovos-skill-easter-eggs + - docker.io/smartgic/ovos-skill-fallback-unknown + - docker.io/smartgic/ovos-skill-ggwave + - docker.io/smartgic/ovos-skill-hello-world + - docker.io/smartgic/ovos-skill-homeassistant + - docker.io/smartgic/ovos-skill-homescreen + - docker.io/smartgic/ovos-skill-jokes + - docker.io/smartgic/ovos-skill-parrot + - docker.io/smartgic/ovos-skill-personal + - docker.io/smartgic/ovos-skill-randomness + - docker.io/smartgic/ovos-skill-volume + - docker.io/smartgic/ovos-skill-weather + - docker.io/smartgic/ovos-skill-wikihow + - docker.io/smartgic/ovos-skill-wikipedia + - docker.io/smartgic/ovos-skill-wolfie + - docker.io/smartgic/ovos-skill-wordnet