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

48 KiB
Raw Blame History

好的,作为一位资深的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应用)

    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:

    sudo apt update
    sudo apt install -y openjdk-17-jdk
    java -version
    
  2. 安装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密钥,并将公钥复制到所有目标节点

  1. 生成SSH密钥对 (在控制节点)

    ssh-keygen -t rsa -b 4096
    # 按提示操作,可选择不设置密码短语以实现完全免密
    
  2. 复制公钥到目标节点 (在控制节点,为每个目标节点执行): 将 ansible_user 替换为目标节点上的用户名,192.168.1.101 替换为目标节点IP。

    ssh-copy-id ansible_user@192.168.1.101
    
  3. 测试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.101ansible_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

注意:上面的 UserGroup 应设置为一个非特权用户。如果使用 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_hostsremote_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.ymlscripts/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 命令行工具。

  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,但如果传递给 shellcommand 模块的参数包含未经验证的用户输入,则需特别小心。示例中的 remote_commandscript_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运行:

      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 中是合适的。

  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
      • 输入指令,例如 hostnamels -l /etc
      • 可选:勾选 "使用 sudo"。
      • 点击 "执行任务"。
    • 测试执行脚本
      • 选择目标主机。
      • 选择 Playbook: execute_script.yml
      • 输入脚本参数 (可选),例如 hello world
      • 可选:勾选 "使用 sudo"。
      • 点击 "执行任务"。

8. 总结与展望

这个示例提供了一个基础但功能完整的概念验证(PoC),展示了如何使用Spring Boot作为前端来控制Ansible执行任务。

可改进和扩展的方向

  • 异步任务执行:对于长时间运行的Ansible Playbook,应使用异步执行(例如通过 @AsyncCompletableFuture),并提供任务状态查询和轮询机制。
  • 实时日志流:将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的结合使用!