好的,作为一位资深的DevOps工程师和Java开发者,我将为您提供一个完整的、详细的示例,展示如何将Ansible与Spring Boot结合使用,实现执行指定脚本和远程机器指令控制。 **核心思想**:我们将创建一个Spring Boot Web应用作为控制台。这个应用提供一个简单的Web界面,用户可以通过该界面选择目标机器、输入要执行的指令或选择预定义的Ansible Playbook任务。Spring Boot应用后端会调用本机的Ansible命令行工具 (`ansible-playbook`) 来执行这些任务,并将结果返回给前端显示。 ## 1. 引言与假设 - **控制节点**:运行Spring Boot管理应用和Ansible CLI的机器。 - **目标节点**:被Ansible管理的机器。 - **网络**:控制节点必须能够通过SSH连接到所有目标节点。 - **用户权限**:执行Ansible Playbook的用户(即运行Spring Boot应用的用户)需要在控制节点上有权执行`ansible-playbook`命令,并配置了到目标节点的SSH免密登录。某些Playbook任务(如安装软件、管理服务)可能需要在目标节点上拥有sudo权限。 - **简单性**:本示例为了清晰易懂,会简化某些生产环境下的复杂配置,例如高级错误处理、异步任务执行等。 ## 2. 环境要求与准备 - **操作系统**:所有机器均为 Ubuntu (例如 Ubuntu 22.04 LTS)。 - **Java版本**:Java 17 (OpenJDK 17)。 - **Ansible**:最新稳定版。 ### 2.1. 目标机器设置 (Target Machine(s)) 假设我们有一个目标机器 `target-node1` (IP: `192.168.1.101`,请替换为您的实际IP)。 1. **安装Java 17 (如果Playbook需要部署Java应用)**: ```bash sudo apt update sudo apt install -y openjdk-17-jdk java -version # 验证安装 ``` 2. **创建SSH用户** (可选,但推荐使用非root用户): 确保目标机器上有一个用户(例如 `ansible_user`),Ansible将通过此用户连接。 ### 2.2. 控制节点设置 (Control Node - where Spring Boot UI app & Ansible CLI run) 这是运行我们的Spring Boot管理应用和Ansible命令的机器。 1. **安装Java 17**: ```bash sudo apt update sudo apt install -y openjdk-17-jdk java -version ``` 2. **安装Maven** (用于构建Spring Boot项目): ```bash sudo apt install -y maven mvn -version ``` ## 3. Ansible 部分 ### 3.1. 在控制节点安装 Ansible ```bash sudo apt update sudo apt install -y software-properties-common sudo add-apt-repository --yes --update ppa:ansible/ansible sudo apt install -y ansible ansible --version # 验证安装 ``` ### 3.2. 配置 SSH 免密登录 (控制节点 -> 目标节点) 在**控制节点**上,为将要运行Spring Boot应用和Ansible的用户(例如当前用户)生成SSH密钥,并将公钥复制到所有**目标节点**。 1. **生成SSH密钥对 (在控制节点)**: ```bash ssh-keygen -t rsa -b 4096 # 按提示操作,可选择不设置密码短语以实现完全免密 ``` 2. **复制公钥到目标节点** (在控制节点,为每个目标节点执行): 将 `ansible_user` 替换为目标节点上的用户名,`192.168.1.101` 替换为目标节点IP。 ```bash ssh-copy-id ansible_user@192.168.1.101 ``` 3. **测试SSH连接** (在控制节点): ```bash ssh ansible_user@192.168.1.101 # 应该无需密码即可登录 ``` ### 3.3. 创建 Ansible 工作目录结构 我们将在Spring Boot项目的 `src/main/resources/ansible` 目录下组织Ansible相关文件。当应用运行时,我们会从这个位置或配置的路径引用它们。 ``` ansible-springboot-mgmt/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ ├── resources/ │ │ │ ├── ansible/ │ │ │ │ ├── inventory/ │ │ │ │ │ └── hosts # Ansible Inventory 文件 │ │ │ │ ├── playbooks/ │ │ │ │ │ ├── deploy_spring_boot_app.yml │ │ │ │ │ ├── execute_command.yml │ │ │ │ │ └── execute_script.yml │ │ │ │ ├── scripts/ │ │ │ │ │ └── example_script.sh │ │ │ │ └── ansible.cfg # Ansible 配置文件 │ │ │ ├── static/ │ │ │ ├── templates/ │ │ │ └── application.properties └── pom.xml ``` ### 3.4. 编写 `ansible.cfg` 文件路径: `src/main/resources/ansible/ansible.cfg` 这个配置文件告诉Ansible在哪里找到Inventory文件,以及其他一些基本设置。 ```ini # src/main/resources/ansible/ansible.cfg [defaults] inventory = ./inventory/hosts # 指向同级目录下的 inventory/hosts 文件 host_key_checking = False # 在开发测试环境中可以禁用主机密钥检查,生产环境慎用! deprecation_warnings = False # 禁用弃用警告,保持输出清洁 # remote_user = ansible_user # 如果所有主机都使用相同的远程用户,可以在此指定 [privilege_escalation] become = true # 允许权限提升 (sudo) become_method = sudo become_user = root become_ask_pass = False # 如果用户配置了免密sudo,则设置为False ``` **注意**:`become_ask_pass = False` 要求目标节点上的 `ansible_user` 用户能够无密码执行 `sudo`。这通常通过配置 `/etc/sudoers.d/ansible_user` 文件实现: `ansible_user ALL=(ALL) NOPASSWD: ALL` 请谨慎配置,这有安全风险。如果不能配置免密sudo,则需要处理密码输入,这会使自动化复杂化。 ### 3.5. 编写 Inventory 文件 (`inventory/hosts`) 文件路径: `src/main/resources/ansible/inventory/hosts` 定义您的目标机器。 ```ini # src/main/resources/ansible/inventory/hosts [webservers] # 格式: 主机别名 ansible_host=IP地址 ansible_user=SSH用户名 # 如果 ansible.cfg 中指定了 remote_user,可以省略 ansible_user target-node1 ansible_host=192.168.1.101 ansible_user=ansible_user # target-node2 ansible_host=192.168.1.102 ansible_user=ansible_user [all:vars] # 全局变量,例如指定python解释器路径 (如果需要) # ansible_python_interpreter=/usr/bin/python3 ``` 将 `192.168.1.101` 和 `ansible_user` 替换为您的实际配置。 ### 3.6. Playbook 1: 部署 Spring Boot 应用 (`playbooks/deploy_spring_boot_app.yml`) 这个Playbook用于将一个简单的Spring Boot应用JAR包部署到目标机器,并作为systemd服务运行。 **首先,准备一个简单的Spring Boot应用供部署:** 创建一个名为 `demo-target-app` 的Spring Boot项目。 `DemoTargetAppApplication.java`: ```java package com.example.demotargetapp; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @SpringBootApplication @RestController public class DemoTargetAppApplication { public static void main(String[] args) { SpringApplication.run(DemoTargetAppApplication.class, args); } @GetMapping("/hello") public String hello() { return "你好,来自已部署的 Spring Boot 应用!端口8080"; } } ``` `pom.xml` (基本Spring Boot Web依赖): ```xml 4.0.0 org.springframework.boot spring-boot-starter-parent 3.2.0 com.example demo-target-app 0.0.1-SNAPSHOT demo-target-app Demo project for Spring Boot to be deployed by Ansible 17 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-maven-plugin ``` 打包该应用: `mvn clean package`. 这会生成 `target/demo-target-app-0.0.1-SNAPSHOT.jar`. **将此JAR包放置到控制节点上Spring Boot管理应用可以访问的路径**,例如,放在与`ansible.cfg`同级的 `files/` 目录下:`src/main/resources/ansible/files/demo-target-app.jar`。 **Playbook内容 (`playbooks/deploy_spring_boot_app.yml`):** ```yaml # src/main/resources/ansible/playbooks/deploy_spring_boot_app.yml - name: 部署 Spring Boot 应用 hosts: "{{ target_hosts | default('webservers') }}" # 允许通过 extra-vars 传递 target_hosts,默认为 webservers 组 become: yes # 大部分任务需要sudo权限 vars: app_name: "demotargetapp" app_jar_name: "demo-target-app.jar" # 将从控制节点复制的JAR文件名 app_jar_source_path: "{{ playbook_dir }}/../files/{{ app_jar_name }}" # JAR包在控制节点上的相对路径 app_deploy_dir: "/opt/{{ app_name }}" app_jar_dest_path: "{{ app_deploy_dir }}/{{ app_jar_name }}" java_executable: "/usr/bin/java" # 假设Java 17的java可执行文件路径 app_port: 8080 # 应用监听的端口 tasks: - name: 确保 Java 17 已安装 (仅检查,不主动安装,假设已由管理员预装) command: "{{ java_executable }} -version" changed_when: false register: java_version_output failed_when: "'openjdk version \"17' not in java_version_output.stderr and 'openjdk version \"17' not in java_version_output.stdout" # 根据实际版本输出调整 - name: 创建应用部署目录 file: path: "{{ app_deploy_dir }}" state: directory mode: '0755' - name: 复制 Spring Boot JAR 包到目标机器 copy: src: "{{ app_jar_source_path }}" dest: "{{ app_jar_dest_path }}" mode: '0644' - name: 创建 systemd 服务文件 template: src: "{{ playbook_dir }}/../templates/spring_boot_service.j2" # 需要一个模板文件 dest: "/etc/systemd/system/{{ app_name }}.service" mode: '0644' notify: Reload systemd and restart app # 触发handler - name: 确保服务已启动并设置为开机自启 systemd: name: "{{ app_name }}" enabled: yes state: started daemon_reload: yes # 在首次创建服务文件后需要 handlers: - name: Reload systemd and restart app systemd: name: "{{ app_name }}" state: restarted daemon_reload: yes ``` **Systemd服务模板 (`src/main/resources/ansible/templates/spring_boot_service.j2`):** ```j2 # src/main/resources/ansible/templates/spring_boot_service.j2 [Unit] Description=Spring Boot Application {{ app_name }} After=network.target [Service] User={{ ansible_user | default('nobody') }} # 运行应用的用户,确保此用户对 JAR 文件有读权限,对日志目录有写权限 Group={{ ansible_user | default('nogroup') }} ExecStart={{ java_executable }} -jar {{ app_jar_dest_path }} --server.port={{ app_port }} SuccessExitStatus=143 # SIGTERM 退出码 Restart=on-failure RestartSec=10 WorkingDirectory={{ app_deploy_dir }} # StandardOutput=append:/var/log/{{ app_name }}/console.log # 可选:将日志输出到文件 # StandardError=append:/var/log/{{ app_name }}/error.log # 可选:将错误日志输出到文件 [Install] WantedBy=multi-user.target ``` **注意**:上面的 `User` 和 `Group` 应设置为一个非特权用户。如果使用 `ansible_user`,确保它存在并且有相应权限。如果应用需要写日志到特定目录 (如 `/var/log/{{ app_name }}`), 需要确保该目录存在且运行用户有写权限。 ### 3.7. Playbook 2: 执行远程指令 (`playbooks/execute_command.yml`) 这个Playbook接收一个命令字符串,并在目标机器上执行它。 ```yaml # src/main/resources/ansible/playbooks/execute_command.yml - name: 执行远程指令 hosts: "{{ target_hosts }}" # 必须通过 extra-vars 传递 target_hosts gather_facts: no # 通常执行简单命令不需要收集facts,加快速度 become: "{{ use_sudo | default(false) | bool }}" # 允许通过 extra-vars 控制是否使用sudo vars: remote_command: "echo '没有提供指令'" # 默认指令,会被 extra-vars 覆盖 tasks: - name: 在目标机器上执行指令 shell: "{{ remote_command }}" # 使用shell模块以支持管道、重定向等 args: executable: /bin/bash # 指定shell,可选 register: command_output changed_when: false # 通常查看类命令不改变系统状态 - name: 显示指令执行结果 debug: var: command_output.stdout_lines ``` 此Playbook期望通过 `--extra-vars` 传递 `target_hosts` 和 `remote_command`。例如: `ansible-playbook execute_command.yml --extra-vars "target_hosts=target-node1 remote_command='ls -l /tmp' use_sudo=false"` ### 3.8. Playbook 3: 执行远程脚本 (`playbooks/execute_script.yml` 和 `scripts/example_script.sh`) 这个Playbook将控制节点上的一个脚本复制到目标机器并执行。 **示例脚本 (`src/main/resources/ansible/scripts/example_script.sh`):** ```bash #!/bin/bash # src/main/resources/ansible/scripts/example_script.sh echo "你好,这是一个来自控制节点的示例脚本。" echo "当前工作目录: $(pwd)" echo "当前用户: $(whoami)" echo "主机名: $(hostname)" echo "传入参数: $@" ``` 确保此脚本有执行权限: `chmod +x src/main/resources/ansible/scripts/example_script.sh` **Playbook (`playbooks/execute_script.yml`):** ```yaml # src/main/resources/ansible/playbooks/execute_script.yml - name: 执行远程脚本 hosts: "{{ target_hosts }}" # 必须通过 extra-vars 传递 target_hosts gather_facts: no become: "{{ use_sudo | default(false) | bool }}" # 允许通过 extra-vars 控制是否使用sudo vars: script_name: "example_script.sh" # 默认脚本名,可被 extra-vars 覆盖 script_path_on_control_node: "{{ playbook_dir }}/../scripts/{{ script_name }}" script_args: "" # 传递给脚本的参数,可被 extra-vars 覆盖 tasks: - name: 在目标机器上执行指定脚本 script: "{{ script_path_on_control_node }} {{ script_args }}" register: script_output changed_when: false # 假设脚本是幂等的或者只是查询信息 - name: 显示脚本执行结果 debug: var: script_output.stdout_lines ``` 此Playbook期望通过 `--extra-vars` 传递 `target_hosts`。可选传递 `script_name`, `script_args`, `use_sudo`。 例如: `ansible-playbook execute_script.yml --extra-vars "target_hosts=target-node1 script_args='arg1 arg2'"` ## 4. Spring Boot 管理应用部分 这是我们的Java 17 Spring Boot应用,它提供Web界面来调用Ansible。 ### 4.1. 项目结构 (回顾) 如2.3节所示。关键在于`src/main/resources/ansible`目录的组织。 ### 4.2. `pom.xml` ```xml 4.0.0 org.springframework.boot spring-boot-starter-parent 3.2.0 com.example ansible-springboot-mgmt 0.0.1-SNAPSHOT ansible-springboot-mgmt Spring Boot App to manage Ansible tasks 17 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-security org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin org.projectlombok lombok ``` ### 4.3. `application.properties` 文件路径: `src/main/resources/application.properties` ```properties # src/main/resources/application.properties server.port=8090 # Spring Security 基本认证配置 spring.security.user.name=admin spring.security.user.password=password # 生产环境请使用强密码或外部认证 # Ansible 相关配置 # Ansible CLI 可执行文件路径,如果不在系统PATH中,则需要指定完整路径 ansible.cli.path=ansible-playbook # Ansible 工作目录的基础路径 (包含 ansible.cfg, playbooks/, inventory/ 等的目录) # 使用 classpath: 会尝试从打包的JAR中读取,这对于playbook执行可能不理想,因为ansible-playbook需要文件系统路径 # 更好的方式是指定一个外部文件系统路径,或者在应用启动时将资源复制到临时目录 # 为简单起见,这里假设这些文件在应用的当前工作目录下的 ansible/ 子目录中 # 或者指定一个绝对路径,例如: /opt/ansible-mgmt-files/ ansible.config.base-path=./src/main/resources/ansible/ # 或者在打包后,如果ansible目录和jar包同级: ansible.config.base-path=./ansible/ # 如果ansible.cfg, inventory等文件在JAR包内,且ansible.config.base-path以classpath:开头 # 则AnsibleService需要处理将这些资源解压到临时目录的逻辑。 # 为了PoC的简单性,我们假设ansible.config.base-path指向一个文件系统路径。 # 运行应用时,确保 Spring Boot 应用的 CWD (Current Working Directory) # 使得 ansible.config.base-path + "ansible.cfg" 等能够正确解析。 # 例如,如果项目根目录运行 `java -jar target/app.jar`,且`ansible.config.base-path=./ansible/`, # 则需要一个 `ansible/` 目录与 `app.jar` 同级。 # 为了在IDE中直接运行,`./src/main/resources/ansible/` 是一个方便的路径。 ``` **重要**:`ansible.config.base-path` 的设置非常关键。 - **在IDE中运行时**: `./src/main/resources/ansible/` 可以工作。 - **作为JAR包运行时**: 你需要确保 `ansible.cfg`, `inventory/`, `playbooks/` 等目录结构相对于应用的工作目录或你指定的绝对路径是正确的。一个常见的做法是将这些Ansible文件打包在JAR包外,例如: ``` my-app-deployment/ ├── ansible-springboot-mgmt.jar └── ansible/ ├── ansible.cfg ├── inventory/ ├── playbooks/ └── scripts/ ``` 此时 `ansible.config.base-path=./ansible/` (如果从 `my-app-deployment` 目录启动JAR)。 ### 4.4. Java 类 #### `SpringBootAnsibleApplication.java` (主类) ```java package com.example.ansiblespringbootmgmt; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; // 主应用类 @SpringBootApplication public class SpringBootAnsibleApplication { public static void main(String[] args) { SpringApplication.run(SpringBootAnsibleApplication.class, args); System.out.println("Spring Boot Ansible 管理应用已启动。访问 http://localhost:8090"); System.out.println("使用用户名 'admin' 和密码 'password' 登录。"); } } ``` #### `SecurityConfig.java` (基本认证) ```java package com.example.ansiblespringbootmgmt.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import static org.springframework.security.config.Customizer.withDefaults; // Spring Security 配置类,用于基本HTTP认证 @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorizeRequests -> authorizeRequests .anyRequest().authenticated() // 所有请求都需要认证 ) .httpBasic(withDefaults()) // 启用HTTP Basic认证 .csrf(csrf -> csrf.disable()); // 为简化示例,禁用CSRF,生产环境需仔细考虑 return http.build(); } // 如果不使用 application.properties 中的 spring.security.user 配置,可以在这里配置用户 // @Bean // public UserDetailsService userDetailsService() { // UserDetails user = User.withDefaultPasswordEncoder() // 不推荐用于生产 // .username("admin") // .password("password") // .roles("USER") // .build(); // return new InMemoryUserDetailsManager(user); // } } ``` #### `AnsibleTaskRequest.java` (DTO) ```java package com.example.ansiblespringbootmgmt.dto; import lombok.Data; // 数据传输对象,用于接收前端的Ansible任务请求 @Data // Lombok注解,自动生成getter, setter, toString等 public class AnsibleTaskRequest { private String targetHosts; // 目标主机或主机组,例如 "target-node1" 或 "webservers" private String playbookName; // 要执行的Playbook文件名,例如 "deploy_spring_boot_app.yml" private String command; // 对于执行命令的任务,这是具体的命令字符串 private String scriptArgs; // 执行脚本时的参数 private Boolean useSudo = false; // 是否使用sudo执行任务 } ``` #### `AnsibleService.java` ```java package com.example.ansiblespringbootmgmt.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; // Ansible服务类,负责执行Ansible命令和解析Inventory @Service public class AnsibleService { @Value("${ansible.cli.path:ansible-playbook}") // 从 application.properties 读取 ansible-playbook 路径,默认为 "ansible-playbook" private String ansiblePlaybookCmd; @Value("${ansible.config.base-path}") // Ansible配置文件和Playbook的基础路径 private String ansibleBasePath; /** * 执行指定的Ansible Playbook。 * @param playbookName Playbook文件名 (例如 "deploy_app.yml") * @param targetHosts 目标主机/组 (例如 "target-node1" 或 "all") * @param extraVars 传递给Playbook的额外变量 (例如 "key1=value1 key2=value2") * @return Ansible命令执行的输出结果 */ public String runPlaybook(String playbookName, String targetHosts, String extraVars) { StringBuilder output = new StringBuilder(); Path playbookFile = Paths.get(ansibleBasePath, "playbooks", playbookName); Path ansibleCfgFile = Paths.get(ansibleBasePath, "ansible.cfg"); if (!Files.exists(playbookFile)) { return "错误: Playbook 文件不存在: " + playbookFile.toAbsolutePath(); } if (!Files.exists(ansibleCfgFile)) { // 如果 ansible.cfg 不存在,ansible-playbook 会使用默认配置或全局配置 // 但为了我们这里的 inventory 路径等设置生效,它应该是存在的。 output.append("警告: ansible.cfg 文件不存在于: ").append(ansibleCfgFile.toAbsolutePath()).append("\n"); } List command = new ArrayList<>(Arrays.asList( ansiblePlaybookCmd, playbookFile.toString(), "-i", Paths.get(ansibleBasePath, "inventory", "hosts").toString() // 指定inventory文件路径 )); if (targetHosts != null && !targetHosts.isEmpty()) { // Playbook内部通常使用 `hosts: {{ target_hosts | default('all') }}` 来接收 // 如果Playbook的hosts字段是硬编码的,这个limit参数可以覆盖它 // 但更推荐的方式是在playbook中使用变量,并通过extra-vars传递 // command.add("--limit"); // command.add(targetHosts); // 对于我们设计的Playbook,我们将target_hosts作为extra_vars传递 if (extraVars == null || extraVars.isEmpty()) { extraVars = "target_hosts=" + targetHosts; } else { extraVars += " target_hosts=" + targetHosts; } } if (extraVars != null && !extraVars.isEmpty()) { command.add("--extra-vars"); command.add(extraVars); // extra-vars应该是一个空格分隔的键值对字符串 } System.out.println("执行 Ansible 命令: " + String.join(" ", command)); try { ProcessBuilder processBuilder = new ProcessBuilder(command); // 设置工作目录为ansible.cfg所在的目录,这样ansible.cfg中的相对路径 (如 inventory = ./inventory/hosts) 才能正确解析 processBuilder.directory(new File(ansibleBasePath)); processBuilder.redirectErrorStream(true); // 合并标准输出和标准错误 Process process = processBuilder.start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); System.out.println(line); // 实时打印到控制台 } } int exitCode = process.waitFor(); output.append("\nAnsible Playbook 执行完毕,退出码: ").append(exitCode); if (exitCode != 0) { output.append("\n警告: Ansible 执行可能存在错误。"); } } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); // 重置中断状态 output.append("\n执行 Ansible 时发生错误: ").append(e.getMessage()); e.printStackTrace(); return output.toString(); } return output.toString(); } /** * 从Ansible Inventory文件中加载主机列表。 * 简单解析INI格式,忽略注释和空行,不处理复杂分组。 * @return 主机名列表 */ public List getInventoryHosts() { List hosts = new ArrayList<>(); Path inventoryPath = Paths.get(ansibleBasePath, "inventory", "hosts"); if (!Files.exists(inventoryPath)) { hosts.add("错误: Inventory文件未找到 " + inventoryPath.toAbsolutePath()); return hosts; } try (Stream lines = Files.lines(inventoryPath)) { hosts = lines .map(String::trim) .filter(line -> !line.isEmpty() && !line.startsWith("#") && !line.startsWith("[")) // 忽略空行、注释和组名 .map(line -> line.split("\\s+")[0]) // 取每行的第一部分作为主机名/别名 .distinct() .collect(Collectors.toList()); } catch (IOException e) { e.printStackTrace(); hosts.add("错误: 读取Inventory文件失败"); } return hosts; } /** * 列出playbooks目录下的所有 .yml 或 .yaml 文件 * @return Playbook文件名列表 */ public List getAvailablePlaybooks() { List playbooks = new ArrayList<>(); Path playbooksDir = Paths.get(ansibleBasePath, "playbooks"); if (!Files.isDirectory(playbooksDir)) { playbooks.add("错误: Playbooks目录未找到 " + playbooksDir.toAbsolutePath()); return playbooks; } try (Stream paths = Files.list(playbooksDir)) { playbooks = paths .filter(Files::isRegularFile) .map(path -> path.getFileName().toString()) .filter(fileName -> fileName.endsWith(".yml") || fileName.endsWith(".yaml")) .collect(Collectors.toList()); } catch (IOException e) { e.printStackTrace(); playbooks.add("错误: 读取Playbooks目录失败"); } return playbooks; } } ``` #### `AnsibleController.java` ```java package com.example.ansiblespringbootmgmt.controller; import com.example.ansiblespringbootmgmt.dto.AnsibleTaskRequest; import com.example.ansiblespringbootmgmt.service.AnsibleService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.beans.factory.annotation.Autowired; import java.util.List; // Web控制器,处理前端请求并与AnsibleService交互 @Controller public class AnsibleController { private final AnsibleService ansibleService; @Autowired public AnsibleController(AnsibleService ansibleService) { this.ansibleService = ansibleService; } // 显示主管理页面 @GetMapping("/") public String index(Model model) { List hosts = ansibleService.getInventoryHosts(); List playbooks = ansibleService.getAvailablePlaybooks(); model.addAttribute("hosts", hosts); model.addAttribute("playbooks", playbooks); model.addAttribute("taskRequest", new AnsibleTaskRequest()); // 用于表单绑定 return "index"; // 返回 index.html 模板 } // 处理执行Playbook的请求 @PostMapping("/run-playbook") public String runPlaybook(@ModelAttribute AnsibleTaskRequest taskRequest, Model model) { StringBuilder extraVarsBuilder = new StringBuilder(); if (taskRequest.getTargetHosts() != null && !taskRequest.getTargetHosts().isEmpty()) { // target_hosts 会被 AnsibleService 自动添加到 extraVars 中 } // 根据Playbook类型构建特定的extraVars if ("execute_command.yml".equals(taskRequest.getPlaybookName())) { if (taskRequest.getCommand() != null && !taskRequest.getCommand().isEmpty()) { // 需要对命令中的特殊字符进行转义或用引号包裹,这里简化处理 extraVarsBuilder.append("remote_command='").append(taskRequest.getCommand().replace("'", "'\\''")).append("' "); } extraVarsBuilder.append("use_sudo=").append(taskRequest.getUseSudo()); } else if ("execute_script.yml".equals(taskRequest.getPlaybookName())) { if (taskRequest.getScriptArgs() != null && !taskRequest.getScriptArgs().isEmpty()) { extraVarsBuilder.append("script_args='").append(taskRequest.getScriptArgs().replace("'", "'\\''")).append("' "); } extraVarsBuilder.append("use_sudo=").append(taskRequest.getUseSudo()); } else if ("deploy_spring_boot_app.yml".equals(taskRequest.getPlaybookName())) { // deploy_spring_boot_app.yml 的 target_hosts 也是通过 extraVars 传递的 // 其他特定变量已在playbook中定义或有默认值 } // (可以为其他playbook添加特定的extra-vars逻辑) String result = ansibleService.runPlaybook( taskRequest.getPlaybookName(), taskRequest.getTargetHosts(), // targetHosts 会被 service 层添加到 extraVars extraVarsBuilder.toString().trim() ); model.addAttribute("result", result); model.addAttribute("hosts", ansibleService.getInventoryHosts()); model.addAttribute("playbooks", ansibleService.getAvailablePlaybooks()); model.addAttribute("taskRequest", taskRequest); // 保留用户输入 return "index"; } } ``` ### 4.5. 前端界面: `src/main/resources/templates/index.html` 使用Thymeleaf模板引擎。 ```html Ansible 控制台 Ansible 任务控制台 选择目标主机/组: -- 请选择 -- 选择 Playbook: -- 请选择 -- 输入指令 (用于 execute_command.yml): 脚本参数 (用于 execute_script.yml): 使用 sudo 执行 (用于 execute_command 和 execute_script) 执行任务 执行结果: ``` ## 5. 交互方式:Spring Boot 调用 Ansible Spring Boot 应用通过 `java.lang.ProcessBuilder` 类来执行 `ansible-playbook` 命令行工具。 1. **构建命令**:`AnsibleService` 根据用户输入(Playbook名称、目标主机、额外参数等)动态构建 `ansible-playbook` 命令及其参数。 - 它会指定 `-i ` 来使用我们配置的Inventory。 - 它会将 `ansible.cfg` 放置在工作目录中,以便Ansible自动加载。 - 它通过 `--extra-vars` 将动态参数(如 `target_hosts`, `remote_command`, `script_args`, `use_sudo`)传递给Playbook。 2. **设置工作目录**:`ProcessBuilder.directory()` 被设置为 `ansibleBasePath`,即包含 `ansible.cfg` 的目录。这确保了 `ansible.cfg` 中的相对路径(如 `inventory = ./inventory/hosts`)能够被正确解析。 3. **执行进程**:`ProcessBuilder.start()` 启动 `ansible-playbook` 进程。 4. **捕获输出**:通过读取进程的输入流(标准输出和标准错误流被合并),获取Ansible的执行日志和结果。 5. **返回结果**:将捕获的输出返回给Controller,最终展示在Web界面上。 这种方式的优点是简单直接,不需要引入额外的Ansible库或Python依赖到Java项目中。缺点是依赖于系统中安装的 `ansible-playbook` CLI,并且输出解析相对原始。 ## 6. 安全性讨论 - **Ansible SSH 密钥**: - **强烈推荐**:使用SSH密钥对进行免密登录,而不是密码。 - **密钥保护**:保护好控制节点上的私钥。如果私钥有密码短语,`ssh-agent` 可以帮助管理。 - **最小权限用户**:在目标节点上,Ansible连接的用户(如 `ansible_user`)应仅拥有执行任务所需的最小权限。 - **Sudo权限 (`become`)**: - 仔细配置目标节点上的 `sudoers` 文件,仅允许 `ansible_user` 无密码执行必要的命令。避免 `NOPASSWD: ALL`,尽可能精确。 - 如果Playbook不需要sudo,则 `become: false` (或省略)。 - **Spring Boot 访问控制**: - 示例中使用了Spring Security的HTTP Basic认证,提供了一个简单的访问屏障。 - **生产环境**:应使用更强的认证机制(如OAuth2/OIDC, LDAP集成)和授权策略。 - **CSRF保护**:示例中为简单禁用了CSRF。生产环境中,对于会改变状态的POST请求,应启用并正确配置CSRF保护。 - **HTTPS**:生产环境必须使用HTTPS加密Spring Boot应用与用户浏览器之间的通信。 - **Playbook 安全性**: - **`no_log: true`**:对于Playbook中处理敏感数据(如密码、API密钥)的任务,使用 `no_log: true` 来防止这些数据出现在Ansible的日志输出中。 - **变量注入**:当从用户输入构建 `extra-vars` 或命令时,要小心潜在的命令注入风险。虽然 `ansible-playbook` 的 `--extra-vars` 通常期望的是键值对或JSON/YAML,但如果传递给 `shell` 或 `command` 模块的参数包含未经验证的用户输入,则需特别小心。示例中的 `remote_command` 和 `script_args` 做了简单的单引号转义,但更复杂的场景可能需要更严格的输入验证和清理。 - **限制Playbook功能**:不要暴露允许执行任意破坏性操作的Playbook给所有用户。 - **`ansible.cfg` 中的 `host_key_checking = False`**: - 这在开发和测试环境中可以简化设置,因为它避免了首次连接新主机时需要手动确认主机密钥。 - **生产环境强烈不推荐**:禁用主机密钥检查会使您容易受到中间人攻击。在生产环境中,应确保主机密钥被正确验证。 ## 7. 完整步骤:运行与测试 1. **准备环境**: - 确保控制节点和目标节点已按第2节要求设置完毕。 - Java 17, Ansible CLI 在控制节点安装好。 - SSH免密登录从控制节点到目标节点已配置。 - 目标节点上Java 17已安装(如果准备部署 `demo-target-app`)。 2. **准备目标Spring Boot应用 (JAR)**: - 编译 `demo-target-app` 项目 (`mvn clean package`)。 - 将生成的 `demo-target-app-0.0.1-SNAPSHOT.jar` 重命名为 `demo-target-app.jar`。 - 将其复制到Spring Boot管理应用项目的 `src/main/resources/ansible/files/demo-target-app.jar`。 3. **配置Spring Boot管理应用**: - 检查 `src/main/resources/application.properties` 中的 `ansible.config.base-path`。如果是在IDE中直接运行`SpringBootAnsibleApplication`,`./src/main/resources/ansible/` 通常是正确的。 - 检查 `src/main/resources/ansible/inventory/hosts` 中的目标主机IP和用户是否正确。 - 检查 `src/main/resources/ansible/ansible.cfg`。特别是如果目标用户需要sudo但未配置免密sudo,`become_ask_pass = False` 会导致问题。 4. **启动Spring Boot管理应用 (在控制节点)**: - 在 `ansible-springboot-mgmt` 项目的根目录下,使用Maven运行: ```bash mvn spring-boot:run ``` - 或者先打包成JAR,然后运行: ```bash mvn clean package java -jar target/ansible-springboot-mgmt-0.0.1-SNAPSHOT.jar ``` 如果作为JAR运行,请确保 `ansible.config.base-path` 指向的路径相对于JAR的运行位置是正确的,并且该路径下有完整的 `ansible` 目录结构 (`ansible.cfg`, `inventory/`, `playbooks/` 等)。例如,可以创建一个 `deploy` 目录: ``` deploy/ ├── ansible-springboot-mgmt-0.0.1-SNAPSHOT.jar └── ansible/ <-- 将 src/main/resources/ansible/ 目录完整复制到这里 ├── ansible.cfg ├── files/ │ └── demo-target-app.jar ├── inventory/ │ └── hosts ├── playbooks/ │ ├── deploy_spring_boot_app.yml │ ├── execute_command.yml │ └── execute_script.yml ├── scripts/ │ └── example_script.sh └── templates/ └── spring_boot_service.j2 ``` 然后进入 `deploy` 目录运行 `java -jar ansible-springboot-mgmt-0.0.1-SNAPSHOT.jar`,此时 `ansible.config.base-path=./ansible/` 在 `application.properties` 中是合适的。 5. **通过 Web UI 操作**: - 打开浏览器,访问 `http://localhost:8090` (或您Spring Boot应用运行的控制节点的IP和端口)。 - 使用用户名 `admin` 和密码 `password` 登录。 - 您会看到一个界面,可以选择目标主机、Playbook,并输入必要的参数。 - **测试部署应用**: - 选择目标主机 (例如 `target-node1`)。 - 选择 Playbook: `deploy_spring_boot_app.yml`。 - 点击 "执行任务"。 - 等待结果显示。如果成功,您应该可以在目标机器的 `http://:8080/hello` 访问到部署的应用。检查目标机器上的服务状态:`systemctl status demotargetapp`。 - **测试执行指令**: - 选择目标主机。 - 选择 Playbook: `execute_command.yml`。 - 输入指令,例如 `hostname` 或 `ls -l /etc`。 - 可选:勾选 "使用 sudo"。 - 点击 "执行任务"。 - **测试执行脚本**: - 选择目标主机。 - 选择 Playbook: `execute_script.yml`。 - 输入脚本参数 (可选),例如 `hello world`。 - 可选:勾选 "使用 sudo"。 - 点击 "执行任务"。 ## 8. 总结与展望 这个示例提供了一个基础但功能完整的概念验证(PoC),展示了如何使用Spring Boot作为前端来控制Ansible执行任务。 **可改进和扩展的方向**: - **异步任务执行**:对于长时间运行的Ansible Playbook,应使用异步执行(例如通过 `@Async` 和 `CompletableFuture`),并提供任务状态查询和轮询机制。 - **实时日志流**:将Ansible的输出实时流式传输到Web界面,而不是等待整个任务完成后一次性显示。这可以通过WebSocket实现。 - **更健壮的错误处理和日志记录**:在Spring Boot应用中加入更完善的错误捕获和日志(例如使用SLF4J)。 - **动态Inventory管理**:允许用户通过界面管理Ansible Inventory,而不是仅依赖文件。 - **Ansible Runner/API**:考虑使用Ansible Runner或Python API(通过Jython或外部进程通信)进行更深度的集成,而不是仅依赖CLI。这可以提供更好的控制和回调。 - **用户管理和权限控制**:集成更完善的用户认证和授权系统,细粒度控制哪些用户可以执行哪些Playbook或针对哪些主机。 - **Playbook和参数的动态加载/配置**:允许用户上传Playbook,或通过更结构化的方式定义Playbook参数,而不是简单的文本输入。 - **安全性增强**:实施所有在第6节中提到的生产环境安全措施。 - **配置管理**:使用Spring Cloud Config等工具管理 `application.properties` 中的配置。 希望这个详细的示例能帮助您理解和实践Spring Boot与Ansible的结合使用!