diff --git a/ansible/inventory-bootstrap.yaml b/ansible/inventory-bootstrap.yaml new file mode 100644 index 0000000..cd4fb56 --- /dev/null +++ b/ansible/inventory-bootstrap.yaml @@ -0,0 +1,55 @@ +all: + children: + tnet: + hosts: + bootstrap-tnet-rust-ceramic-1.3box.io: + peers: + - /dns4/bootstrap-tnet-rust-ceramic-2.3box.io/tcp/4101/p2p/12D3KooWPFGbRHWfDaWt5MFFeqAHBBq3v5BqeJ4X7pmn2V1t6uNs + rust_ceramic_pk_name: "bootstrap-tnet-rust-ceramic-1-pk" + bootstrap-tnet-rust-ceramic-2.3box.io: + peers: + - /dns4/bootstrap-tnet-rust-ceramic-1.3box.io/tcp/4101/p2p/12D3KooWMqCFj5bnwuNi6D6KLhYiK4C8Eh9xSUKv2E6Jozs4nWEE + rust_ceramic_pk_name: "bootstrap-tnet-rust-ceramic-2-pk" + vars: + ceramic_network: testnet-clay + gcp_project: tnet-prod-2024 + ssh_key_cd_pub: "{{ lookup('gcp_secret', gcp_project, 'bootstrap-tnet-prod-id_rsa-pub') }}" + mainnet: + hosts: + bootstrap-mainnet-rust-ceramic-1.3box.io: + peers: + - /dns4/bootstrap-mainnet-rust-ceramic-2.3box.io/tcp/4101/p2p/12D3KooWCuS388c1im7KkmdrpsLMziihF8mbcv2w6HPCp4Qmww6m + rust_ceramic_pk_name: "bootstrap-mainnet-rust-ceramic-1-pk" + bootstrap-mainnet-rust-ceramic-2.3box.io: + peers: + - /dns4/bootstrap-mainnet-rust-ceramic-1.3box.io/tcp/4101/p2p/12D3KooWJC1yR4KiCnocV9kuAEwtsMNh7Xmu2vzqpBvk2o3MrYd6 + rust_ceramic_pk_name: "bootstrap-mainnet-rust-ceramic-2-pk" + vars: + ceramic_network: mainnet + gcp_project: tnet-prod-2024 + ssh_key_cd_pub: "{{ lookup('gcp_secret', gcp_project, 'bootstrap-tnet-prod-id_rsa-pub') }}" + devqa: + hosts: + bootstrap-devqa-rust-ceramic-1.3box.io: + peers: + - /dns4/bootstrap-devqa-rust-ceramic-2.3box.io/tcp/4101/p2p/12D3KooWFCf7sKeW8NHoT35EutjJX5vCpPekYqa4hB4tTUpYrcam + rust_ceramic_pk_name: "bootstrap-devqa-rust-ceramic-1-pk" + bootstrap-devqa-rust-ceramic-2.3box.io: + peers: + - /dns4/bootstrap-devqa-rust-ceramic-1.3box.io/tcp/4101/p2p/12D3KooWJmYPnXgst4gW5GoyAYzRB3upLgLVR1oDVGwjiS9Ce7sA + rust_ceramic_pk_name: "bootstrap-devqa-rust-ceramic-2-pk" + vars: + ceramic_network: dev-unstable + gcp_project: dev-qa-2023 + ssh_key_cd_pub: "{{ lookup('gcp_secret', gcp_project, 'bootstrap-devqa-id_rsa-pub') }}" + + vars: + caddy_proxy_port: 8000 + caddy_tls_email: infra@3box.io + internal_hostname: "{{ inventory_hostname.split('.')[0].replace('gitcoin-gcp-', '') }}" + ceramic_versions_path: /tmp + rust_ceramic_data_block_path: /dev/disk/by-id/google-rust-ceramic-data + rust_ceramic_data_mount_path: /rust_ceramic_data_disk + rust_ceramic_store_path: /rust_ceramic_data_disk/ceramic-one + rust_ceramic_pk_dir: /rust_ceramic_data_disk/keys + rust_ceramic_download_url: "https://github.com/ceramicnetwork/rust-ceramic/releases/download/{{ rust_ceramic_version }}/ceramic-one_x86_64-unknown-linux-gnu.tar.gz" diff --git a/ansible/pip.requirements.txt b/ansible/pip.requirements.txt index 07d2c59..72f6287 100644 --- a/ansible/pip.requirements.txt +++ b/ansible/pip.requirements.txt @@ -1,10 +1,29 @@ ansible==7.2.0 ansible-core==2.14.2 +base58==2.1.1 +cachetools==5.3.3 +certifi==2024.7.4 cffi==1.15.1 +charset-normalizer==3.3.2 cryptography==39.0.1 +google-api-core==2.19.1 +google-auth==2.32.0 +google-cloud-secret-manager==2.20.1 +googleapis-common-protos==1.63.2 +grpc-google-iam-v1==0.13.1 +grpcio==1.65.0 +grpcio-status==1.65.0 +idna==3.7 Jinja2==3.1.2 MarkupSafe==2.1.2 packaging==23.0 +proto-plus==1.24.0 +protobuf==5.27.2 +pyasn1==0.6.0 +pyasn1_modules==0.4.0 pycparser==2.21 PyYAML==6.0 +requests==2.32.3 resolvelib==0.8.1 +rsa==4.9 +urllib3==2.2.2 diff --git a/ansible/playbooks/bootstrap-update.yaml b/ansible/playbooks/bootstrap-update.yaml new file mode 100644 index 0000000..0809ed4 --- /dev/null +++ b/ansible/playbooks/bootstrap-update.yaml @@ -0,0 +1,18 @@ +--- +- hosts: devqa + serial: 1 + become: true + roles: + - rust-ceramic + +- hosts: tnet + serial: 1 + become: true + roles: + - rust-ceramic + +- hosts: mainnet + serial: 1 + become: true + roles: + - rust-ceramic diff --git a/ansible/playbooks/bootstrap.yaml b/ansible/playbooks/bootstrap.yaml new file mode 100644 index 0000000..d3a5c6b --- /dev/null +++ b/ansible/playbooks/bootstrap.yaml @@ -0,0 +1,26 @@ +--- +- hosts: all + become: true + roles: + - name: ceramic-prep + import_role: + name: ceramic-prep + tags: ceramic-prep + +- hosts: all + become: true + roles: + - name: rust-ceramic + import_role: + name: rust-ceramic + tags: rust-ceramic + + - name: bootstrap-ui + import_role: + name: bootstrap-ui + tags: bootstrap-ui + + - name: caddy + import_role: + name: caddy + tags: caddy diff --git a/ansible/playbooks/lookup_plugins/gcp_secret.py b/ansible/playbooks/lookup_plugins/gcp_secret.py new file mode 100644 index 0000000..a8b381a --- /dev/null +++ b/ansible/playbooks/lookup_plugins/gcp_secret.py @@ -0,0 +1,36 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import subprocess +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible.utils.display import Display + +display = Display() + +class LookupModule(LookupBase): + + def run(self, terms, variables=None, **kwargs): + display.debug("GCP Secret lookup plugin called") + if len(terms) != 2: + raise AnsibleError("gcp_secret lookup expects 2 arguments: [project_id, secret_name]") + + project_id, secret_name = terms + display.debug(f"Looking up secret {secret_name} in project {project_id}") + + try: + cmd = [ + "gcloud", "secrets", "versions", "access", "latest", + f"--secret={secret_name}", + f"--project={project_id}", + "--quiet" + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + display.debug("Secret accessed successfully using gcloud") + except subprocess.CalledProcessError as e: + display.error(f"Error accessing secret: {e}") + raise AnsibleError(f"Error accessing secret: {e.stderr}") + + secret_value = result.stdout.strip() + display.v(f"Retrieved secret '{secret_name}' from project '{project_id}'") + return [secret_value] diff --git a/ansible/playbooks/lookup_plugins/github_latest_release.py b/ansible/playbooks/lookup_plugins/github_latest_release.py new file mode 100644 index 0000000..235b4f7 --- /dev/null +++ b/ansible/playbooks/lookup_plugins/github_latest_release.py @@ -0,0 +1,34 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import requests +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible.utils.display import Display + +display = Display() + + +class LookupModule(LookupBase): + + def run(self, terms, variables=None, **kwargs): + display.debug("GitHub Latest Release lookup plugin called") + if len(terms) != 1: + raise AnsibleError("github_latest_release lookup expects 1 argument: [repo_name]") + + repo_name = terms[0] + display.debug(f"Looking up latest release for repository {repo_name}") + + try: + url = f"https://api.github.com/repos/{repo_name}/releases/latest" + response = requests.get(url) + response.raise_for_status() + data = response.json() + latest_version = data['tag_name'] + display.debug(f"Latest release version retrieved: {latest_version}") + except requests.RequestException as e: + display.error(f"Error accessing GitHub API: {e}") + raise AnsibleError(f"Error accessing GitHub API: {str(e)}") + + display.v(f"Retrieved latest release version '{latest_version}' for repository '{repo_name}'") + return [latest_version] diff --git a/ansible/playbooks/monitoring.yaml b/ansible/playbooks/monitoring.yaml index 6718688..9c944a4 100644 --- a/ansible/playbooks/monitoring.yaml +++ b/ansible/playbooks/monitoring.yaml @@ -6,3 +6,8 @@ import_role: name: grafana-alloy tags: grafana-alloy + vars: + grafana_prom_username: "{{ lookup('gcp_secret', gcp_project, 'grafana-prom-username') }}" + grafana_prom_password: "{{ lookup('gcp_secret', gcp_project, 'grafana-prom-password') }}" + grafana_loki_username: "{{ lookup('gcp_secret', gcp_project, 'grafana-loki-username') }}" + grafana_loki_password: "{{ lookup('gcp_secret', gcp_project, 'grafana-loki-password') }}" diff --git a/ansible/roles/bootstrap-ui/files/bootstrap-ui.service b/ansible/roles/bootstrap-ui/files/bootstrap-ui.service new file mode 100644 index 0000000..33d6287 --- /dev/null +++ b/ansible/roles/bootstrap-ui/files/bootstrap-ui.service @@ -0,0 +1,14 @@ +[Unit] +Description=Bootstrap UI +After=network.target + +[Service] +WorkingDirectory=/opt/ceramic-one-ui +ExecStart=/opt/ceramic-one-ui/.venv/bin/python /opt/ceramic-one-ui/main.py +Restart=always +RestartSec=10 +Environment=PYTHONUNBUFFERED=1 +MemoryLimit=100M + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/bootstrap-ui/files/main.py b/ansible/roles/bootstrap-ui/files/main.py new file mode 100644 index 0000000..0c96c6e --- /dev/null +++ b/ansible/roles/bootstrap-ui/files/main.py @@ -0,0 +1,165 @@ +import asyncio +import signal +from datetime import datetime +from version import get_ceramic_one_version +from peers import get_swarm_peers +from fastapi import FastAPI +from fastapi.responses import JSONResponse, HTMLResponse +import uvicorn +import aiohttp + +# Add configuration option for sleep interval (in seconds) +CACHE_UPDATE_INTERVAL = 60 # Default: 30 seconds + +# Create a global event to signal shutdown +shutdown_event = asyncio.Event() + +# Create a global dictionary to store the cache state +cache_state = {} + +app = FastAPI() + + +def handle_shutdown_signal(): + print("Shutdown signal received. Exiting gracefully...") + shutdown_event.set() + + +async def get_ceramic_id(): + async with aiohttp.ClientSession() as session: + try: + async with session.post('http://localhost:5101/api/v0/id') as response: + if response.status == 200: + data = await response.json() + return { + "ID": data.get("ID"), + "Addresses": data.get("Addresses", []) + } + else: + return {"error": f"Failed to get Ceramic ID. Status: {response.status}"} + except aiohttp.ClientError as e: + return {"error": f"Failed to connect to Ceramic node: {str(e)}"} + + +async def update_cache(): + while not shutdown_event.is_set(): + timestamp = datetime.now().isoformat() + + # Get Ceramic version + ceramic_version = get_ceramic_one_version() + + # Get swarm peers + swarm_peers = get_swarm_peers() + + # Get Ceramic ID + ceramic_id = await get_ceramic_id() + + # Create cache entry + cache_entry = { + "timestamp": timestamp, + "ceramic_version": ceramic_version, + "swarm_peers": swarm_peers, + "ceramic_id": ceramic_id + } + # Save cache entry to global dictionary + cache_state[timestamp] = cache_entry + print(f"Cache updated at {timestamp}") + + # Use the configured interval + await asyncio.sleep(CACHE_UPDATE_INTERVAL) + + +@app.get("/latest") +async def get_latest_cache(): + if not cache_state: + return JSONResponse(content={"error": "Cache is empty"}, status_code=404) + latest_timestamp = max(cache_state.keys()) + return JSONResponse(content=cache_state[latest_timestamp]) + + +@app.get("/", response_class=HTMLResponse) +async def get_root(): + html_content = """ + + + + + + Ceramic bootstrap node + + + +

Ceramic bootstrap node Cache Status

+
Loading...
+ + + """ + return HTMLResponse(content=html_content) + + +async def run_fastapi(): + config = uvicorn.Config(app, host="127.0.0.1", port=8000, loop="asyncio") + server = uvicorn.Server(config) + await server.serve() + + +async def main(): + update_task = asyncio.create_task(update_cache()) + fastapi_task = asyncio.create_task(run_fastapi()) + await asyncio.gather(update_task, fastapi_task) + +if __name__ == "__main__": + print("Starting cache updater and FastAPI server...") + print(f"Cache update interval: {CACHE_UPDATE_INTERVAL} seconds") + + # Register signal handlers + signal.signal(signal.SIGTERM, lambda s, f: handle_shutdown_signal()) + signal.signal(signal.SIGINT, lambda s, f: handle_shutdown_signal()) + + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("Keyboard interrupt received. Exiting gracefully...") diff --git a/ansible/roles/bootstrap-ui/files/peers.py b/ansible/roles/bootstrap-ui/files/peers.py new file mode 100644 index 0000000..4d760d8 --- /dev/null +++ b/ansible/roles/bootstrap-ui/files/peers.py @@ -0,0 +1,36 @@ +import requests +import json + + +def get_swarm_peers(): + url = "http://localhost:5101/api/v0/swarm/peers" + + try: + # Send POST request + response = requests.post(url, timeout=10) + + # Raise an exception for bad status codes + response.raise_for_status() + + # Parse JSON response + json_response = response.json() + + return json_response + + except requests.exceptions.RequestException as e: + print(f"An error occurred while making the request: {e}") + return None + + except json.JSONDecodeError as e: + print(f"Error decoding JSON response: {e}") + return None + + +if __name__ == "__main__": + result = get_swarm_peers() + + if result is not None: + print("Response received:") + print(json.dumps(result, indent=2)) + else: + print("Failed to get a valid response.") diff --git a/ansible/roles/bootstrap-ui/files/requirements.txt b/ansible/roles/bootstrap-ui/files/requirements.txt new file mode 100644 index 0000000..91eddd2 --- /dev/null +++ b/ansible/roles/bootstrap-ui/files/requirements.txt @@ -0,0 +1,39 @@ +annotated-types==0.7.0 +anyio==4.4.0 +certifi==2024.7.4 +charset-normalizer==3.3.2 +click==8.1.7 +dnspython==2.6.1 +email_validator==2.2.0 +fastapi==0.111.0 +fastapi-cli==0.0.4 +h11==0.14.0 +httpcore==1.0.5 +httptools==0.6.1 +httpx==0.27.0 +idna==3.7 +Jinja2==3.1.4 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +mdurl==0.1.2 +orjson==3.10.6 +pydantic==2.8.2 +pydantic_core==2.20.1 +Pygments==2.18.0 +python-dotenv==1.0.1 +python-multipart==0.0.9 +PyYAML==6.0.1 +requests==2.32.3 +rich==13.7.1 +shellingham==1.5.4 +sniffio==1.3.1 +starlette==0.37.2 +typer==0.12.3 +typing_extensions==4.12.2 +ujson==5.10.0 +urllib3==2.2.2 +uvicorn==0.30.1 +uvloop==0.19.0 +watchfiles==0.22.0 +websockets==12.0 +aiohttp==3.9.5 diff --git a/ansible/roles/bootstrap-ui/files/version.py b/ansible/roles/bootstrap-ui/files/version.py new file mode 100644 index 0000000..720fe67 --- /dev/null +++ b/ansible/roles/bootstrap-ui/files/version.py @@ -0,0 +1,38 @@ +import subprocess +import re + + +def get_ceramic_one_version(): + try: + # Execute the command and capture the output + result = subprocess.run(['ceramic-one', '--version'], capture_output=True, text=True, check=True) + + # Parse the output + match = re.search(r'ceramic-one (\d+\.\d+\.\d+)', result.stdout) + + if match: + version = match.group(1) + return version + else: + raise ValueError("Unable to parse version from output") + + except subprocess.CalledProcessError as e: + print(f"Error executing command: {e}") + return None + except FileNotFoundError: + print("ceramic-one command not found. Make sure it's installed and in your PATH.") + return None + except ValueError as e: + print(f"Error parsing version: {e}") + return None + + +if __name__ == "__main__": + + # Call the function and store the result + ceramic_one_version = get_ceramic_one_version() + + if ceramic_one_version: + print(f"Ceramic One version: {ceramic_one_version}") + else: + print("Failed to retrieve Ceramic One version.") diff --git a/ansible/roles/bootstrap-ui/tasks/main.yaml b/ansible/roles/bootstrap-ui/tasks/main.yaml new file mode 100644 index 0000000..561ae56 --- /dev/null +++ b/ansible/roles/bootstrap-ui/tasks/main.yaml @@ -0,0 +1,49 @@ +--- +- name: Install python venv + apt: + name: python3-venv + state: present + +- name: Create app directory + file: + path: /opt/ceramic-one-ui + state: directory + owner: root + group: root + mode: 0755 + +- name: Copy app files + copy: + src: "{{ item }}" + dest: /opt/ceramic-one-ui + with_items: + - files/ + register: copy_result + +- name: Create a virtual environment + shell: python3 -m venv /opt/ceramic-one-ui/.venv + args: + creates: /opt/ceramic-one-ui/.venv + +- name: Install requirements + pip: + requirements: /opt/ceramic-one-ui/requirements.txt + virtualenv: /opt/ceramic-one-ui/.venv + register: pip_result + +- name: Template systemd service file + copy: + src: bootstrap-ui.service + dest: /etc/systemd/system/bootstrap-ui.service + +- name: Enable and start the service + systemd: + name: bootstrap-ui + state: started + enabled: true + +- name: Restart the service when changed + systemd: + name: bootstrap-ui + state: restarted + when: copy_result.changed or pip_result.changed \ No newline at end of file diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index 4753678..87df1fc 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -1,4 +1,4 @@ {{ inventory_hostname }} { - reverse_proxy localhost:7007 - tls infra@3box.io + reverse_proxy localhost:{{ caddy_proxy_port }} + tls {{ caddy_tls_email }} } diff --git a/ansible/roles/ceramic-prep/tasks/main.yaml b/ansible/roles/ceramic-prep/tasks/main.yaml index ce856ea..b8ee1c3 100644 --- a/ansible/roles/ceramic-prep/tasks/main.yaml +++ b/ansible/roles/ceramic-prep/tasks/main.yaml @@ -63,3 +63,22 @@ state: directory owner: ceramic when: rust_ceramic_data_block_path is defined + +- name: Add CD user + block: + - name: Add Ansible user + ansible.builtin.user: + name: ansible + state: present + shell: /bin/bash + home: /home/ansible + - name: Add Ansible user to sudoers + ansible.builtin.lineinfile: + path: /etc/sudoers + line: "ansible ALL=(ALL) NOPASSWD:ALL" + state: present + - name: Update user's authorized_keys + ansible.builtin.authorized_key: + user: ansible + key: "{{ ssh_key_cd_pub }}" + when: ssh_key_cd_pub is defined diff --git a/ansible/roles/rust-ceramic/handlers/main.yml b/ansible/roles/rust-ceramic/handlers/main.yml new file mode 100644 index 0000000..ed0bbe6 --- /dev/null +++ b/ansible/roles/rust-ceramic/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart-rust-ceramic + ansible.builtin.systemd: + name: rust-ceramic + state: restarted diff --git a/ansible/roles/rust-ceramic/tasks/main.yaml b/ansible/roles/rust-ceramic/tasks/main.yaml index 7c33feb..ca14ed3 100644 --- a/ansible/roles/rust-ceramic/tasks/main.yaml +++ b/ansible/roles/rust-ceramic/tasks/main.yaml @@ -6,17 +6,31 @@ state: directory mode: '0755' -- name: Create the rust-ceramic store directory if it doesn't exist +- name: Create the rust-ceramic directories if they don't exist ansible.builtin.file: - path: "{{ rust_ceramic_store_path }}" + path: "{{ item }}" state: directory mode: '0755' owner: ceramic + loop: + - "{{ rust_ceramic_store_path }}" + - "{{ rust_ceramic_pk_dir }}" + +- name: Write the rust-ceramic pk to the pk dir + ansible.builtin.copy: + content: "{{ lookup('gcp_secret', gcp_project, rust_ceramic_pk_name) }}" + dest: "{{ rust_ceramic_pk_dir }}/id_ed25519_0" + no_log: true # Don't log the private key + +- name: Get latest rust-ceramic release + ansible.builtin.set_fact: + rust_ceramic_version: "{{ lookup('github_latest_release', 'ceramicnetwork/rust-ceramic') }}" - name: Download the Ceramic package ansible.builtin.get_url: url: "{{ rust_ceramic_download_url }}" dest: "{{ ceramic_versions_path }}/ceramic-one_{{ rust_ceramic_version }}.tar.gz" + notify: restart-rust-ceramic - name: Extract the Ceramic package ansible.builtin.unarchive: @@ -31,6 +45,7 @@ state: present when: rust_ceramic_extract.changed register: rust_ceramic_install + notify: restart-rust-ceramic - name: "Setup Ceramic systemd unit" become: yes diff --git a/ansible/roles/rust-ceramic/templates/rust-ceramic.env.j2 b/ansible/roles/rust-ceramic/templates/rust-ceramic.env.j2 index edf4b33..cefe48d 100644 --- a/ansible/roles/rust-ceramic/templates/rust-ceramic.env.j2 +++ b/ansible/roles/rust-ceramic/templates/rust-ceramic.env.j2 @@ -5,5 +5,6 @@ CERAMIC_ONE_LOG_FORMAT=json CERAMIC_ONE_METRICS_BIND_ADDRESS=0.0.0.0:9465 CERAMIC_ONE_NETWORK={{ ceramic_network }} CERAMIC_ONE_STORE_DIR={{ rust_ceramic_store_path }} +CERAMIC_ONE_P2P_KEY_DIR={{ rust_ceramic_pk_dir }} CERAMIC_ONE_SWARM_ADDRESSES=/ip4/0.0.0.0/tcp/4101 CERAMIC_ONE_EXTRA_CERAMIC_PEER_ADDRESSES={{ peers | join(',') }}