48 KiB
好的,作为一位资深的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)。
-
安装Java 17 (如果Playbook需要部署Java应用):
sudo apt update sudo apt install -y openjdk-17-jdk java -version # 验证安装 -
创建SSH用户 (可选,但推荐使用非root用户): 确保目标机器上有一个用户(例如
ansible_user),Ansible将通过此用户连接。
2.2. 控制节点设置 (Control Node - where Spring Boot UI app & Ansible CLI run)
这是运行我们的Spring Boot管理应用和Ansible命令的机器。
-
安装Java 17:
sudo apt update sudo apt install -y openjdk-17-jdk java -version -
安装Maven (用于构建Spring Boot项目):
sudo apt install -y maven mvn -version
3. Ansible 部分
3.1. 在控制节点安装 Ansible
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密钥,并将公钥复制到所有目标节点。
-
生成SSH密钥对 (在控制节点):
ssh-keygen -t rsa -b 4096 # 按提示操作,可选择不设置密码短语以实现完全免密 -
复制公钥到目标节点 (在控制节点,为每个目标节点执行): 将
ansible_user替换为目标节点上的用户名,192.168.1.101替换为目标节点IP。ssh-copy-id ansible_user@192.168.1.101 -
测试SSH连接 (在控制节点):
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文件,以及其他一些基本设置。
# 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
定义您的目标机器。
# 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:
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 version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version> <!-- 使用与Java 17兼容的Spring Boot版本 -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-target-app</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo-target-app</name>
<description>Demo project for Spring Boot to be deployed by Ansible</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
打包该应用: 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):
# 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):
# 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接收一个命令字符串,并在目标机器上执行它。
# 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):
#!/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):
# 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 version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version> <!-- Java 17 兼容 -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>ansible-springboot-mgmt</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ansible-springboot-mgmt</name>
<description>Spring Boot App to manage Ansible tasks</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <!-- 用于基本认证 -->
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.3. application.properties
文件路径: src/main/resources/application.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 (主类)
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 (基本认证)
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)
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
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<String> 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<String> getInventoryHosts() {
List<String> hosts = new ArrayList<>();
Path inventoryPath = Paths.get(ansibleBasePath, "inventory", "hosts");
if (!Files.exists(inventoryPath)) {
hosts.add("错误: Inventory文件未找到 " + inventoryPath.toAbsolutePath());
return hosts;
}
try (Stream<String> 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<String> getAvailablePlaybooks() {
List<String> playbooks = new ArrayList<>();
Path playbooksDir = Paths.get(ansibleBasePath, "playbooks");
if (!Files.isDirectory(playbooksDir)) {
playbooks.add("错误: Playbooks目录未找到 " + playbooksDir.toAbsolutePath());
return playbooks;
}
try (Stream<Path> 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
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<String> hosts = ansibleService.getInventoryHosts();
List<String> 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模板引擎。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Ansible 控制台</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
.container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
h1 { color: #333; }
label { display: block; margin-top: 10px; font-weight: bold; }
select, input[type="text"], input[type="checkbox"] {
width: calc(100% - 22px); padding: 10px; margin-top: 5px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;
}
input[type="checkbox"] { width: auto; margin-right: 5px; vertical-align: middle; }
button {
background-color: #5cb85c; color: white; padding: 10px 15px; border: none;
border-radius: 4px; cursor: pointer; font-size: 16px; margin-top: 20px;
}
button:hover { background-color: #4cae4c; }
pre {
background-color: #282c34; color: #abb2bf; padding: 15px; border-radius: 4px;
overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
max-height: 400px;
}
.form-group { margin-bottom: 15px; }
.error { color: red; }
</style>
<script>
// 页面加载时根据选择的playbook显示/隐藏特定输入框
document.addEventListener('DOMContentLoaded', function() {
const playbookSelect = document.getElementById('playbookName');
const commandGroup = document.getElementById('commandGroup');
const scriptArgsGroup = document.getElementById('scriptArgsGroup');
function toggleInputs() {
const selectedPlaybook = playbookSelect.value;
commandGroup.style.display = (selectedPlaybook === 'execute_command.yml') ? 'block' : 'none';
scriptArgsGroup.style.display = (selectedPlaybook === 'execute_script.yml') ? 'block' : 'none';
}
playbookSelect.addEventListener('change', toggleInputs);
toggleInputs(); // 初始化时调用
});
</script>
</head>
<body>
<div class="container">
<h1>Ansible 任务控制台</h1>
<form th:action="@{/run-playbook}" th:object="${taskRequest}" method="post">
<div class="form-group">
<label for="targetHosts">选择目标主机/组:</label>
<select id="targetHosts" th:field="*{targetHosts}" required>
<option value="">-- 请选择 --</option>
<option th:each="host : ${hosts}"
th:if="${!host.startsWith('错误:')}"
th:value="${host}"
th:text="${host}"></option>
<!-- 如果 inventory 文件读取错误,显示错误信息 -->
<option th:each="host : ${hosts}"
th:if="${host.startsWith('错误:')}"
th:value="''"
th:text="${host}" disabled class="error"></option>
</select>
</div>
<div class="form-group">
<label for="playbookName">选择 Playbook:</label>
<select id="playbookName" th:field="*{playbookName}" required>
<option value="">-- 请选择 --</option>
<option th:each="playbook : ${playbooks}"
th:if="${!playbook.startsWith('错误:')}"
th:value="${playbook}"
th:text="${playbook}"></option>
<option th:each="playbook : ${playbooks}"
th:if="${playbook.startsWith('错误:')}"
th:value="''"
th:text="${playbook}" disabled class="error"></option>
</select>
</div>
<div class="form-group" id="commandGroup" style="display:none;">
<label for="command">输入指令 (用于 execute_command.yml):</label>
<input type="text" id="command" th:field="*{command}" placeholder="例如: ls -l /tmp 或 uptime"/>
</div>
<div class="form-group" id="scriptArgsGroup" style="display:none;">
<label for="scriptArgs">脚本参数 (用于 execute_script.yml):</label>
<input type="text" id="scriptArgs" th:field="*{scriptArgs}" placeholder="例如: arg1 arg2"/>
</div>
<div class="form-group">
<input type="checkbox" id="useSudo" th:field="*{useSudo}" />
<label for="useSudo" style="display: inline; font-weight: normal;">使用 sudo 执行 (用于 execute_command 和 execute_script)</label>
</div>
<button type="submit">执行任务</button>
</form>
<div th:if="${result}" style="margin-top: 20px;">
<h2>执行结果:</h2>
<pre th:text="${result}"></pre>
</div>
</div>
</body>
</html>
5. 交互方式:Spring Boot 调用 Ansible
Spring Boot 应用通过 java.lang.ProcessBuilder 类来执行 ansible-playbook 命令行工具。
- 构建命令:
AnsibleService根据用户输入(Playbook名称、目标主机、额外参数等)动态构建ansible-playbook命令及其参数。- 它会指定
-i <inventory_file_path>来使用我们配置的Inventory。 - 它会将
ansible.cfg放置在工作目录中,以便Ansible自动加载。 - 它通过
--extra-vars将动态参数(如target_hosts,remote_command,script_args,use_sudo)传递给Playbook。
- 它会指定
- 设置工作目录:
ProcessBuilder.directory()被设置为ansibleBasePath,即包含ansible.cfg的目录。这确保了ansible.cfg中的相对路径(如inventory = ./inventory/hosts)能够被正确解析。 - 执行进程:
ProcessBuilder.start()启动ansible-playbook进程。 - 捕获输出:通过读取进程的输入流(标准输出和标准错误流被合并),获取Ansible的执行日志和结果。
- 返回结果:将捕获的输出返回给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. 完整步骤:运行与测试
-
准备环境:
- 确保控制节点和目标节点已按第2节要求设置完毕。
- Java 17, Ansible CLI 在控制节点安装好。
- SSH免密登录从控制节点到目标节点已配置。
- 目标节点上Java 17已安装(如果准备部署
demo-target-app)。
-
准备目标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。
- 编译
-
配置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会导致问题。
- 检查
-
启动Spring Boot管理应用 (在控制节点):
-
在
ansible-springboot-mgmt项目的根目录下,使用Maven运行:mvn spring-boot:run -
或者先打包成JAR,然后运行:
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中是合适的。
-
-
通过 Web UI 操作:
- 打开浏览器,访问
http://localhost:8090(或您Spring Boot应用运行的控制节点的IP和端口)。 - 使用用户名
admin和密码password登录。 - 您会看到一个界面,可以选择目标主机、Playbook,并输入必要的参数。
- 测试部署应用:
- 选择目标主机 (例如
target-node1)。 - 选择 Playbook:
deploy_spring_boot_app.yml。 - 点击 "执行任务"。
- 等待结果显示。如果成功,您应该可以在目标机器的
http://<target-node-ip>: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的结合使用!