1088 lines
48 KiB
Markdown
1088 lines
48 KiB
Markdown
好的,作为一位资深的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的结合使用!
|