Files
notes/resource/组件/Ansible 与 Java 结合.md
2026-03-01 01:43:46 +08:00

1088 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
好的,作为一位资深的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
<?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`):**
```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
<?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`
```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<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`
```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模板引擎。
```html
<!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` 命令行工具。
1. **构建命令**`AnsibleService` 根据用户输入(Playbook名称、目标主机、额外参数等)动态构建 `ansible-playbook` 命令及其参数。
- 它会指定 `-i <inventory_file_path>` 来使用我们配置的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://<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的结合使用!