diff --git a/init.sh b/init.sh index 4ad5e8c..2b63546 100755 --- a/init.sh +++ b/init.sh @@ -34,7 +34,18 @@ fi PUBKEY=$(cat "$PUBKEY_PATH") +echo "" +echo "--- JuiceFS / Nextcloud Storage ---" +read -p "S3 endpoint (e.g. https://s3.amazonaws.com): " JUICEFS_S3_ENDPOINT +read -p "S3 bucket name: " JUICEFS_S3_BUCKET +read -p "S3 access key: " JUICEFS_S3_ACCESS_KEY +read -s -p "S3 secret key: " JUICEFS_S3_SECRET_KEY +echo +read -p "JuiceFS local cache size [50G]: " JUICEFS_CACHE_SIZE +JUICEFS_CACHE_SIZE="${JUICEFS_CACHE_SIZE:-50G}" + export SCRIPT_DIR HOST OLD_USER NEW_USER SSH_TARGET PUBKEY USER_PASSWORD +export JUICEFS_S3_ENDPOINT JUICEFS_S3_BUCKET JUICEFS_S3_ACCESS_KEY JUICEFS_S3_SECRET_KEY JUICEFS_CACHE_SIZE source "$SCRIPT_DIR/scripts/01_create_user.sh" source "$SCRIPT_DIR/scripts/02_remove_old_user.sh" diff --git a/playbook.yml b/playbook.yml index 6da1311..a9ff1f7 100644 --- a/playbook.yml +++ b/playbook.yml @@ -7,12 +7,19 @@ base_domain: pangolin_domain: "pangolin.{{ base_domain }}" forgejo_domain: "forgejo.{{ base_domain }}" - opencode_domain: "opencode.{{ base_domain }}" + muxplex_domain: "muxplex.{{ base_domain }}" + nextcloud_domain: "nextcloud.{{ base_domain }}" acme_email: "admin@{{ base_domain }}" docker_hosting_dir: "/home/{{ login_user }}/docker_hosting" resources_dir: "{{ playbook_dir }}/resources" login_user: "{{ ansible_env.SUDO_USER | default(ansible_user_id) }}" deb_architecture: "{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' if ansible_architecture == 'aarch64' else ansible_architecture }}" + ssh_pubkey: "" + juicefs_s3_endpoint: "" + juicefs_s3_bucket: "" + juicefs_s3_access_key: "" + juicefs_s3_secret_key: "" + juicefs_cache_size: "50G" tasks: - name: Update apt cache @@ -48,9 +55,8 @@ - python3 - python3-pip - python3-venv - - nodejs - - npm - jq + - ttyd state: present # --- Server hardening --- @@ -142,12 +148,12 @@ port: '21820' proto: udp - - name: UFW - Allow Docker to reach opencode on host + - name: UFW - Allow Docker to reach muxplex on host community.general.ufw: rule: allow from_ip: 172.18.0.0/16 to_ip: any - port: '3000' + port: '8088' proto: tcp - name: UFW - Enable firewall @@ -231,7 +237,7 @@ creates: "/home/{{ login_user }}/.cargo/bin/rustup" become_user: "{{ login_user }}" - - name: Install uv (Python package manager) + - name: Install uv (Python package manager) for login user ansible.builtin.shell: cmd: curl -LsSf https://astral.sh/uv/install.sh | sh args: @@ -294,6 +300,23 @@ ansible.builtin.set_fact: pangolin_secret: "{{ pangolin_secret_result.stdout }}" + - name: Generate Nextcloud admin password + ansible.builtin.shell: + cmd: openssl rand -base64 24 + register: nextcloud_admin_pass_result + changed_when: false + + - name: Generate Nextcloud DB password + ansible.builtin.shell: + cmd: openssl rand -base64 24 + register: nextcloud_db_pass_result + changed_when: false + + - name: Set Nextcloud password facts + ansible.builtin.set_fact: + nextcloud_admin_pass: "{{ nextcloud_admin_pass_result.stdout }}" + nextcloud_db_pass: "{{ nextcloud_db_pass_result.stdout }}" + # --- Directory structure --- - name: Create docker_hosting directory structure @@ -310,6 +333,9 @@ - docker_data/pangolin/logs - docker_data/pangolin/traefik - docker_data/pangolin/traefik/logs + - docker_data/nextcloud + - docker_data/nextcloud/db + - docker_data/nextcloud/redis - name: Create Forgejo data directory ansible.builtin.file: @@ -354,7 +380,7 @@ group: "{{ login_user }}" mode: '0644' - # --- OpenCode / Claude Code --- + # --- aicoder user setup --- - name: Create aicoder user ansible.builtin.user: @@ -363,64 +389,181 @@ home: /home/aicoder create_home: yes - - name: Create codeprojects directory + - name: Create aicoder directories ansible.builtin.file: - path: /home/aicoder/codeprojects + path: "/home/aicoder/{{ item }}" state: directory owner: aicoder group: aicoder mode: '0755' + loop: + - codeprojects + - scripts + - .ssh - - name: Install tmux - ansible.builtin.apt: - name: tmux - state: present - - - name: Install opencode for aicoder - ansible.builtin.shell: - cmd: curl -fsSL https://opencode.ai/install | bash - args: - creates: /home/aicoder/.opencode/bin/opencode - become_user: aicoder - - - name: Install Claude Code for aicoder - ansible.builtin.shell: - cmd: npm install -g @anthropic-ai/claude-code - args: - creates: /home/aicoder/.npm-global/lib/node_modules/@anthropic-ai/claude-code - become_user: aicoder - environment: - NPM_CONFIG_PREFIX: /home/aicoder/.npm-global - PATH: "/home/aicoder/.npm-global/bin:{{ ansible_env.PATH }}" - - - name: Create opencode systemd service + - name: Add SSH key for aicoder ansible.builtin.copy: - dest: /etc/systemd/system/opencode.service - mode: '0644' - content: | - [Unit] - Description=OpenCode Server - After=network.target + dest: /home/aicoder/.ssh/authorized_keys + content: "{{ ssh_pubkey }}\n" + owner: aicoder + group: aicoder + mode: '0600' + when: ssh_pubkey | length > 0 - [Service] - Type=simple - User=aicoder - WorkingDirectory=/home/aicoder/codeprojects - ExecStart=/home/aicoder/.opencode/bin/opencode serve --port 3000 --hostname 0.0.0.0 - Restart=on-failure - RestartSec=5 + - name: Set aicoder .ssh directory permissions + ansible.builtin.file: + path: /home/aicoder/.ssh + mode: '0700' + owner: aicoder + group: aicoder - [Install] - WantedBy=multi-user.target + - name: Install nvm for aicoder + ansible.builtin.shell: + cmd: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash + args: + creates: /home/aicoder/.nvm/nvm.sh + become_user: aicoder - - name: Start and enable opencode service + - name: Install latest LTS Node.js via nvm + ansible.builtin.shell: + cmd: bash -c 'source ~/.nvm/nvm.sh && nvm install --lts' + args: + creates: /home/aicoder/.nvm/alias/lts + become_user: aicoder + + - name: Install Claude Code via nvm npm + ansible.builtin.shell: + cmd: bash -c 'source ~/.nvm/nvm.sh && npm install -g @anthropic-ai/claude-code' + become_user: aicoder + args: + creates: /home/aicoder/.nvm/versions/node + + - name: Install pi-coding-agent via nvm npm + ansible.builtin.shell: + cmd: bash -c 'source ~/.nvm/nvm.sh && npm install -g @mariozechner/pi-coding-agent' + become_user: aicoder + + - name: Install uv for aicoder + ansible.builtin.shell: + cmd: curl -LsSf https://astral.sh/uv/install.sh | sh + args: + creates: /home/aicoder/.local/bin/uv + become_user: aicoder + + - name: Install muxplex for aicoder + ansible.builtin.shell: + cmd: /home/aicoder/.local/bin/uv tool install git+https://github.com/bkrabach/muxplex + become_user: aicoder + args: + creates: /home/aicoder/.local/bin/muxplex + + - name: Configure muxplex to bind to all interfaces + ansible.builtin.shell: + cmd: /home/aicoder/.local/bin/muxplex config set host 0.0.0.0 + become_user: aicoder + + - name: Configure muxplex auth to none (Pangolin handles auth) + ansible.builtin.shell: + cmd: /home/aicoder/.local/bin/muxplex config set auth none + become_user: aicoder + + - name: Install muxplex as systemd service + ansible.builtin.shell: + cmd: /home/aicoder/.local/bin/muxplex service install + become_user: aicoder + + - name: Enable and start muxplex service ansible.builtin.systemd: - name: opencode + name: muxplex state: started enabled: yes - daemon_reload: yes + scope: user + become_user: aicoder - # --- Start services --- + - name: Enable lingering for aicoder (keep user services running) + ansible.builtin.shell: + cmd: loginctl enable-linger aicoder + args: + creates: /var/lib/systemd/linger/aicoder + + # --- Cron cleanup script --- + + - name: Create build artifact cleanup script + ansible.builtin.copy: + dest: /home/aicoder/scripts/cleanup-build-artifacts.sh + owner: aicoder + group: aicoder + mode: '0755' + content: | + #!/bin/bash + + PROJECTS_DIR="/home/aicoder/codeprojects" + MAX_AGE_DAYS=4 + + # Rust - remove target directories + find "$PROJECTS_DIR" -maxdepth 3 -type d -name "target" -mtime +$MAX_AGE_DAYS -exec rm -rf {} + + + # Node - remove node_modules directories + find "$PROJECTS_DIR" -maxdepth 3 -type d -name "node_modules" -mtime +$MAX_AGE_DAYS -exec rm -rf {} + + + - name: Set up cron job for build artifact cleanup + ansible.builtin.cron: + name: "cleanup build artifacts" + user: aicoder + minute: "0" + hour: "3" + job: "/home/aicoder/scripts/cleanup-build-artifacts.sh" + + # --- JuiceFS + Nextcloud directories --- + + - name: Create JuiceFS and Nextcloud data directories + ansible.builtin.file: + path: "{{ docker_hosting_dir }}/{{ item }}" + state: directory + owner: "{{ login_user }}" + group: "{{ login_user }}" + mode: '0755' + loop: + - docker_data/nextcloud/data + - docker_data/juicefs/cache + + # --- Start infrastructure services (Redis needs to be up before JuiceFS format) --- + + - name: Start Redis via docker compose + ansible.builtin.shell: + cmd: docker compose up -d redis + chdir: "{{ docker_hosting_dir }}" + + - name: Wait for Redis to be ready + ansible.builtin.shell: + cmd: docker exec redis redis-cli ping + register: redis_ping + until: redis_ping.stdout == "PONG" + retries: 10 + delay: 3 + + - name: Format JuiceFS filesystem + ansible.builtin.shell: + cmd: > + docker run --rm --network pangolin + juicedata/mount:ce-v1 + juicefs format + --storage s3 + --bucket {{ juicefs_s3_endpoint }}/{{ juicefs_s3_bucket }} + --access-key {{ juicefs_s3_access_key }} + --secret-key {{ juicefs_s3_secret_key }} + "redis://redis:6379/1" + nextcloud-data + args: + creates: /etc/juicefs-formatted + + - name: Mark JuiceFS as formatted + ansible.builtin.file: + path: /etc/juicefs-formatted + state: touch + mode: '0644' + + # --- Start all services --- - name: Start all services via docker compose ansible.builtin.shell: diff --git a/resources/compose.yml.j2 b/resources/compose.yml.j2 index bf12562..d3a957c 100644 --- a/resources/compose.yml.j2 +++ b/resources/compose.yml.j2 @@ -1,8 +1,13 @@ +# Image pinning strategy: +# Pangolin/Gerbil: minor-pinned (newer projects, higher risk of breaking changes) +# Everything else: major-pinned (get security patches automatically) +# Run `docker compose pull && docker compose up -d` to update within pinned range + name: pangolin services: pangolin: - image: docker.io/fosrl/pangolin:latest + image: docker.io/fosrl/pangolin:1.17 container_name: pangolin restart: unless-stopped volumes: @@ -14,7 +19,7 @@ services: retries: 15 gerbil: - image: docker.io/fosrl/gerbil:latest + image: docker.io/fosrl/gerbil:1.3 container_name: gerbil restart: unless-stopped depends_on: @@ -53,7 +58,7 @@ services: - ./docker_data/pangolin/traefik/logs:/var/log/traefik forgejo: - image: codeberg.org/forgejo/forgejo:7 + image: codeberg.org/forgejo/forgejo:15 container_name: forgejo restart: unless-stopped volumes: @@ -68,6 +73,103 @@ services: - FORGEJO__server__START_SSH_SERVER=false - FORGEJO__server__SSH_PORT=22 + redis: + image: redis:7-alpine + container_name: redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - ./docker_data/nextcloud/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: "5s" + timeout: "3s" + retries: 10 + + juicefs: + image: juicedata/mount:ce-v1 + container_name: juicefs + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + command: > + juicefs mount + --foreground + --cache-dir /var/cache/juicefs + --cache-size {{ juicefs_cache_size | regex_replace('[^0-9]', '') | int * 1024 }} + --buffer-size 300 + --prefetch 1 + "redis://redis:6379/1" + /mnt/nextcloud + devices: + - /dev/fuse + cap_add: + - SYS_ADMIN + security_opt: + - apparmor:unconfined + volumes: + - ./docker_data/nextcloud/data:/mnt/nextcloud:rshared + - ./docker_data/juicefs/cache:/var/cache/juicefs + healthcheck: + test: ["CMD", "mountpoint", "-q", "/mnt/nextcloud"] + interval: "10s" + timeout: "5s" + retries: 10 + + nextcloud-db: + image: mariadb:11 + container_name: nextcloud-db + restart: unless-stopped + volumes: + - ./docker_data/nextcloud/db:/var/lib/mysql + environment: + - MYSQL_ROOT_PASSWORD={{ nextcloud_db_pass }} + - MYSQL_DATABASE=nextcloud + - MYSQL_USER=nextcloud + - MYSQL_PASSWORD={{ nextcloud_db_pass }} + command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: "10s" + timeout: "5s" + retries: 10 + + nextcloud: + image: nextcloud:33 + container_name: nextcloud + restart: unless-stopped + depends_on: + nextcloud-db: + condition: service_healthy + juicefs: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./docker_data/nextcloud/data:/var/www/html:rshared + environment: + - MYSQL_HOST=nextcloud-db + - MYSQL_DATABASE=nextcloud + - MYSQL_USER=nextcloud + - MYSQL_PASSWORD={{ nextcloud_db_pass }} + - NEXTCLOUD_ADMIN_USER=admin + - NEXTCLOUD_ADMIN_PASSWORD={{ nextcloud_admin_pass }} + - NEXTCLOUD_TRUSTED_DOMAINS={{ nextcloud_domain }} + - OVERWRITEPROTOCOL=https + - OVERWRITEHOST={{ nextcloud_domain }} + - REDIS_HOST=redis + + autoheal: + image: willfarrell/autoheal:1 + container_name: autoheal + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - AUTOHEAL_CONTAINER_LABEL=all + - AUTOHEAL_INTERVAL=30 + networks: default: driver: bridge diff --git a/scripts/03_run_playbook.sh b/scripts/03_run_playbook.sh index 0d9baf0..6fb3555 100755 --- a/scripts/03_run_playbook.sh +++ b/scripts/03_run_playbook.sh @@ -5,5 +5,13 @@ echo "=== Phase 3: Copying files and running playbook ===" rsync -az --exclude '.git' "$SCRIPT_DIR/" "$NEW_USER@pangolin.$HOST:~/server_init/" +EXTRA_VARS="base_domain=$HOST" +EXTRA_VARS="$EXTRA_VARS ssh_pubkey=\"$PUBKEY\"" +EXTRA_VARS="$EXTRA_VARS juicefs_s3_endpoint=$JUICEFS_S3_ENDPOINT" +EXTRA_VARS="$EXTRA_VARS juicefs_s3_bucket=$JUICEFS_S3_BUCKET" +EXTRA_VARS="$EXTRA_VARS juicefs_s3_access_key=$JUICEFS_S3_ACCESS_KEY" +EXTRA_VARS="$EXTRA_VARS juicefs_s3_secret_key=$JUICEFS_S3_SECRET_KEY" +EXTRA_VARS="$EXTRA_VARS juicefs_cache_size=$JUICEFS_CACHE_SIZE" + ssh -t "$NEW_USER@pangolin.$HOST" \ - "bash -c 'sudo locale-gen en_US.UTF-8 && if ! command -v ansible &>/dev/null; then echo \"Installing Ansible...\"; sudo apt-get update && sudo apt-get install -y ansible-core python3-pip; fi && echo \"Running Ansible playbook...\" && cd ~/server_init && LC_ALL=en_US.UTF-8 ansible-playbook -i localhost, -c local playbook.yml -e base_domain=\"$HOST\" --ask-become-pass'" + "bash -c 'sudo locale-gen en_US.UTF-8 && if ! command -v ansible &>/dev/null; then echo \"Installing Ansible...\"; sudo apt-get update && sudo apt-get install -y ansible-core python3-pip; fi && echo \"Running Ansible playbook...\" && cd ~/server_init && LC_ALL=en_US.UTF-8 ansible-playbook -i localhost, -c local playbook.yml -e \"$EXTRA_VARS\" --ask-become-pass'" diff --git a/scripts/04_show_setup_info.sh b/scripts/04_show_setup_info.sh index cb2e69c..648e304 100755 --- a/scripts/04_show_setup_info.sh +++ b/scripts/04_show_setup_info.sh @@ -12,6 +12,7 @@ echo " Server setup complete!" echo "===========================================" echo "" echo " SSH: ssh $NEW_USER@pangolin.$HOST" +echo " SSH (aicoder): ssh aicoder@pangolin.$HOST" echo "" echo " Pangolin setup token: $SETUP_TOKEN" echo "" @@ -20,8 +21,15 @@ echo " 1. Go to https://pangolin.$HOST/auth/initial-setup" echo " and enter the setup token above to create your admin account." echo "" echo " 2. In the Pangolin dashboard, add a local site, then create" -echo " a public resource for opencode:" +echo " resources for the following services:" +echo "" +echo " Muxplex (web terminal):" echo " - Target: host.docker.internal" -echo " - Port: 3000" -echo " - Domain: opencode.$HOST" +echo " - Port: 8088" +echo " - Domain: muxplex.$HOST" +echo "" +echo " Nextcloud:" +echo " - Target: nextcloud" +echo " - Port: 80" +echo " - Domain: nextcloud.$HOST" echo "==========================================="