chore: add k8s and docker as service to agent and update logic for ansible deploy
ci-agent / build (push) Failing after 2m35s
ci-agent / build (push) Failing after 2m35s
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(DockerLogSource)
|
||||||
|
|
||||||
|
// DockerLogSource reads logs from a Docker container via `docker logs -f`.
|
||||||
|
type DockerLogSource struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stdoutscanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine implements logsource.LogSource.
|
||||||
|
func (d *DockerLogSource) ReadLine() (string, error) {
|
||||||
|
if d.stdoutscanner.Scan() {
|
||||||
|
return d.stdoutscanner.Text(), nil
|
||||||
|
} else {
|
||||||
|
if d.stdoutscanner.Err() == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||||
|
}
|
||||||
|
return "", d.stdoutscanner.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements logsource.LogSource.
|
||||||
|
func (d *DockerLogSource) Close() error {
|
||||||
|
_ = d.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
return d.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Docker log source for the given container.
|
||||||
|
// The container name is taken from cfg.Path (if set) or cfg.Name.
|
||||||
|
func New(cfg config.ServiceConfig) (*DockerLogSource, error) {
|
||||||
|
containerName := cfg.Name
|
||||||
|
if cfg.Path != nil && *cfg.Path != "" {
|
||||||
|
containerName = *cfg.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker logs -f --tail=0 --no-color <container_name>
|
||||||
|
// -f : follow new logs
|
||||||
|
// --tail=0 : skip existing logs
|
||||||
|
// --no-color: strip color codes for clean output
|
||||||
|
cmd := exec.Command("docker", "logs", "-f", "--tail=0", "--no-color", containerName) //nolint:gosec
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stdout pipe for docker logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also capture stderr since docker logs merges stdout and stderr from the container
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stderr pipe for docker logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start docker logs for container %q: %w", containerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use MultiReader to merge stdout and stderr
|
||||||
|
// Docker logs outputs container stdout+stderr to its own stdout, but we also
|
||||||
|
// capture the docker CLI's stderr separately in case of errors (e.g. container not found)
|
||||||
|
stdoutscanner := bufio.NewScanner(stdout)
|
||||||
|
|
||||||
|
// Start a goroutine to consume stderr (we don't send docker CLI stderr as logs,
|
||||||
|
// but we need to prevent the pipe from filling up)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
_, err := stderr.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &DockerLogSource{cmd, stdout, stdoutscanner}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(KubernetesLogSource)
|
||||||
|
|
||||||
|
// KubernetesLogSource reads logs from a Kubernetes pod via `kubectl logs -f`.
|
||||||
|
type KubernetesLogSource struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stdoutscanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine implements logsource.LogSource.
|
||||||
|
func (k *KubernetesLogSource) ReadLine() (string, error) {
|
||||||
|
if k.stdoutscanner.Scan() {
|
||||||
|
return k.stdoutscanner.Text(), nil
|
||||||
|
} else {
|
||||||
|
if k.stdoutscanner.Err() == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||||
|
}
|
||||||
|
return "", k.stdoutscanner.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements logsource.LogSource.
|
||||||
|
func (k *KubernetesLogSource) Close() error {
|
||||||
|
_ = k.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
return k.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Kubernetes log source for the given pod.
|
||||||
|
// The pod identifier is taken from cfg.Path in the format "namespace/podname".
|
||||||
|
// If no namespace is specified (just "podname"), "default" namespace is used.
|
||||||
|
// If cfg.Path is nil or empty, cfg.Name is used as the pod name with "default" namespace.
|
||||||
|
func New(cfg config.ServiceConfig) (*KubernetesLogSource, error) {
|
||||||
|
podName := cfg.Name
|
||||||
|
namespace := "default"
|
||||||
|
|
||||||
|
if cfg.Path != nil && *cfg.Path != "" {
|
||||||
|
parts := strings.SplitN(*cfg.Path, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
namespace = parts[0]
|
||||||
|
podName = parts[1]
|
||||||
|
} else {
|
||||||
|
podName = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kubectl logs -f <pod> -n <namespace> --tail=0 --no-color
|
||||||
|
// -f : follow new logs
|
||||||
|
// --tail=0 : skip existing logs
|
||||||
|
// --no-color: strip color codes for clean output
|
||||||
|
cmd := exec.Command("kubectl", "logs", "-f", podName, "-n", namespace, "--tail=0", "--no-color") //nolint:gosec
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stdout pipe for kubectl logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stderr pipe for kubectl logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start kubectl logs for pod %q (ns: %q): %w", podName, namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutscanner := bufio.NewScanner(stdout)
|
||||||
|
|
||||||
|
// Consume stderr to prevent pipe from filling up
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
_, err := stderr.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &KubernetesLogSource{cmd, stdout, stdoutscanner}, nil
|
||||||
|
}
|
||||||
@@ -14,8 +14,10 @@ import (
|
|||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/docker"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/kubernetes"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
@@ -147,6 +149,16 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
|
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
|
||||||
}
|
}
|
||||||
|
case "docker":
|
||||||
|
src, err = docker.New(svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create docker source for container %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
case "kubernetes":
|
||||||
|
src, err = kubernetes.New(svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create kubernetes source for pod %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
|
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,32 +7,39 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrUnknownDeployType is returned when an unsupported deployment type is specified
|
||||||
|
var ErrUnknownDeployType = fmt.Errorf("unknown deploy type, expected 'docker' or 'binary'")
|
||||||
|
|
||||||
// Executor handles running Ansible playbooks
|
// Executor handles running Ansible playbooks
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
workDir string
|
workDir string
|
||||||
grpcServerHost string
|
grpcServerHost string
|
||||||
grpcServerPort string
|
grpcServerPort string
|
||||||
backendURL string
|
backendURL string
|
||||||
|
giteaReleasesURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutorConfig holds configuration for the Executor
|
// ExecutorConfig holds configuration for the Executor
|
||||||
type ExecutorConfig struct {
|
type ExecutorConfig struct {
|
||||||
WorkDir string
|
WorkDir string
|
||||||
GRPCServerHost string
|
GRPCServerHost string
|
||||||
GRPCServerPort string
|
GRPCServerPort string
|
||||||
BackendURL string
|
BackendURL string
|
||||||
|
GiteaReleasesURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExecutor creates a new Ansible executor
|
// NewExecutor creates a new Ansible executor
|
||||||
func NewExecutor(cfg ExecutorConfig) *Executor {
|
func NewExecutor(cfg ExecutorConfig) *Executor {
|
||||||
return &Executor{
|
return &Executor{
|
||||||
workDir: cfg.WorkDir,
|
workDir: cfg.WorkDir,
|
||||||
grpcServerHost: cfg.GRPCServerHost,
|
grpcServerHost: cfg.GRPCServerHost,
|
||||||
grpcServerPort: cfg.GRPCServerPort,
|
grpcServerPort: cfg.GRPCServerPort,
|
||||||
backendURL: cfg.BackendURL,
|
backendURL: cfg.BackendURL,
|
||||||
|
giteaReleasesURL: cfg.GiteaReleasesURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +62,42 @@ func (e *Executor) GRPCURL() string {
|
|||||||
return e.grpcServerHost + ":" + e.grpcServerPort
|
return e.grpcServerHost + ":" + e.grpcServerPort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckDockerCollection verifies that the community.docker Ansible collection is installed.
|
||||||
|
// Returns an error if the collection is not found.
|
||||||
|
func (e *Executor) CheckDockerCollection() error {
|
||||||
|
cmd := exec.Command("ansible-galaxy", "collection", "list", "community.docker")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("community.docker collection not found: %s", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ansible-galaxy collection list returns output like:
|
||||||
|
// # /usr/share/ansible/collections/ansible_collections
|
||||||
|
// Collection Version
|
||||||
|
// ---------------- -------
|
||||||
|
// community.docker 3.10.0
|
||||||
|
//
|
||||||
|
// If the collection is not installed, it won't appear in the output.
|
||||||
|
if !strings.Contains(stdout.String(), "community.docker") {
|
||||||
|
return fmt.Errorf("community.docker collection is not installed. Run: ansible-galaxy collection install community.docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Deploy runs Ansible playbook for the given inventory
|
// Deploy runs Ansible playbook for the given inventory
|
||||||
func (e *Executor) Deploy(
|
func (e *Executor) Deploy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
inventoryPath string,
|
inventoryPath string,
|
||||||
deployType string,
|
deployType string,
|
||||||
) ([]DeployResult, error) {
|
) ([]DeployResult, error) {
|
||||||
|
if deployType != "docker" && deployType != "binary" {
|
||||||
|
return nil, fmt.Errorf("invalid deploy type %q: %w", deployType, ErrUnknownDeployType)
|
||||||
|
}
|
||||||
|
|
||||||
playbookName := "binary_deploy.yml"
|
playbookName := "binary_deploy.yml"
|
||||||
if deployType == "docker" {
|
if deployType == "docker" {
|
||||||
playbookName = "docker_deploy.yml"
|
playbookName = "docker_deploy.yml"
|
||||||
@@ -72,6 +109,7 @@ func (e *Executor) Deploy(
|
|||||||
"-i", inventoryPath,
|
"-i", inventoryPath,
|
||||||
"-e", fmt.Sprintf("backend_url=%s", e.backendURL),
|
"-e", fmt.Sprintf("backend_url=%s", e.backendURL),
|
||||||
"-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort),
|
"-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort),
|
||||||
|
"-e", fmt.Sprintf("gitea_releases_url=%s", e.giteaReleasesURL),
|
||||||
playbookPath,
|
playbookPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +138,7 @@ func (e *Executor) DeployParallel(
|
|||||||
deployType string,
|
deployType string,
|
||||||
) (map[string][]DeployResult, error) {
|
) (map[string][]DeployResult, error) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
results := make(map[string][]DeployResult)
|
results := make(map[string][]DeployResult)
|
||||||
errCh := make(chan error, len(inventoryPaths))
|
errCh := make(chan error, len(inventoryPaths))
|
||||||
|
|
||||||
@@ -111,7 +150,9 @@ func (e *Executor) DeployParallel(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
|
mu.Lock()
|
||||||
results[p] = res
|
results[p] = res
|
||||||
|
mu.Unlock()
|
||||||
}(path)
|
}(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,13 @@ type Inventory struct {
|
|||||||
Hosts []InventoryHost
|
Hosts []InventoryHost
|
||||||
}
|
}
|
||||||
|
|
||||||
const inventoryTemplateText = `{{ range .Hosts }}
|
const inventoryTemplateText = `{{- range $i, $host := .Hosts }}
|
||||||
{{ .Name }} ansible_host={{ .IP }} ansible_port={{ .Port }} ansible_user={{ .User }} ansible_connection=ssh
|
{{ $host.Name }} ansible_host={{ $host.IP }} ansible_port={{ $host.Port }} ansible_user={{ $host.User }} ansible_connection=ssh{{ if eq $host.AuthMethod "key" }} ansible_ssh_private_key_file={{ $host.SSHKey }}{{ end }}{{ if eq $host.AuthMethod "password" }} ansible_ssh_pass={{ $host.Password }}{{ end }}
|
||||||
{{ if eq .AuthMethod "key" }}ansible_ssh_private_key_file={{ .SSHKey }}{{ end }}
|
deploy_type={{ $host.DeployType }}
|
||||||
{{ if eq .AuthMethod "password" }}ansible_ssh_pass={{ .Password }}{{ end }}
|
agent_token={{ $host.Token }}
|
||||||
deploy_type={{ .DeployType }}
|
agent_label={{ $host.Name }}
|
||||||
agent_token={{ .Token }}
|
grpc_url={{ $host.GRPCURL }}
|
||||||
agent_label={{ .Name }}
|
{{ end -}}`
|
||||||
grpc_url={{ .GRPCURL }}
|
|
||||||
|
|
||||||
{{ end }}`
|
|
||||||
|
|
||||||
// GenerateInventory generates an Ansible inventory file from the given hosts
|
// GenerateInventory generates an Ansible inventory file from the given hosts
|
||||||
func GenerateInventory(hosts []InventoryHost, outputPath string) error {
|
func GenerateInventory(hosts []InventoryHost, outputPath string) error {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package ansible
|
package ansible
|
||||||
|
|
||||||
// BinaryDeployPlaybook returns the Ansible playbook for binary deployment.
|
// BinaryDeployPlaybook returns the Ansible playbook for binary deployment.
|
||||||
// Downloads the agent binary, writes config, and starts it directly (no systemd).
|
// Downloads the agent binary, writes config, and installs a systemd unit for automatic restart.
|
||||||
// systemd unit is managed separately (e.g. via goreleaser .deb/.rpm packages).
|
|
||||||
const BinaryDeployPlaybook = `---
|
const BinaryDeployPlaybook = `---
|
||||||
- name: Deploy HellreigN Agent (Binary)
|
- name: Deploy HellreigN Agent (Binary)
|
||||||
hosts: all
|
hosts: all
|
||||||
@@ -14,6 +13,7 @@ const BinaryDeployPlaybook = `---
|
|||||||
install_dir: /opt/hellreign
|
install_dir: /opt/hellreign
|
||||||
bin_name: hellreign-agent
|
bin_name: hellreign-agent
|
||||||
cert_dir: "{{ install_dir }}/certs"
|
cert_dir: "{{ install_dir }}/certs"
|
||||||
|
gitea_releases_url: "{{ gitea_releases_url | default('https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download') }}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Create installation directory
|
- name: Create installation directory
|
||||||
@@ -30,7 +30,7 @@ const BinaryDeployPlaybook = `---
|
|||||||
|
|
||||||
- name: Download HellreigN Agent binary
|
- name: Download HellreigN Agent binary
|
||||||
get_url:
|
get_url:
|
||||||
url: "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download/{{ bin_name }}"
|
url: "{{ gitea_releases_url }}/{{ bin_name }}"
|
||||||
dest: "{{ install_dir }}/{{ bin_name }}"
|
dest: "{{ install_dir }}/{{ bin_name }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
@@ -48,16 +48,43 @@ const BinaryDeployPlaybook = `---
|
|||||||
dest: "{{ install_dir }}/config.yml"
|
dest: "{{ install_dir }}/config.yml"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
|
|
||||||
- name: Start HellreigN Agent
|
- name: Create systemd unit file
|
||||||
shell: |
|
copy:
|
||||||
nohup {{ install_dir }}/{{ bin_name }} > /dev/null 2>&1 &
|
content: |
|
||||||
echo $!
|
[Unit]
|
||||||
args:
|
Description=HellreigN Agent
|
||||||
executable: /bin/bash
|
After=network-online.target
|
||||||
environment:
|
Wants=network-online.target
|
||||||
CONFIG_FILE: "{{ install_dir }}/config.yml"
|
|
||||||
register: agent_pid
|
[Service]
|
||||||
changed_when: true
|
Type=simple
|
||||||
|
ExecStart={{ install_dir }}/{{ bin_name }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=CONFIG_FILE={{ install_dir }}/config.yml
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
dest: /etc/systemd/system/hellreign-agent.service
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Reload systemd daemon
|
||||||
|
systemd:
|
||||||
|
daemon_reload: yes
|
||||||
|
|
||||||
|
- name: Enable and start HellreigN Agent service
|
||||||
|
systemd:
|
||||||
|
name: hellreign-agent
|
||||||
|
enabled: yes
|
||||||
|
state: started
|
||||||
|
|
||||||
|
- name: Wait for agent to start
|
||||||
|
pause:
|
||||||
|
seconds: 3
|
||||||
|
|
||||||
|
- name: Verify HellreigN Agent is running
|
||||||
|
command: systemctl is-active --quiet hellreign-agent
|
||||||
|
changed_when: false
|
||||||
`
|
`
|
||||||
|
|
||||||
// DockerDeployPlaybook returns the Ansible playbook for Docker deployment.
|
// DockerDeployPlaybook returns the Ansible playbook for Docker deployment.
|
||||||
@@ -72,7 +99,9 @@ const DockerDeployPlaybook = `---
|
|||||||
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
|
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
|
||||||
container_name: hellreign-agent-{{ agent_label }}
|
container_name: hellreign-agent-{{ agent_label }}
|
||||||
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
|
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
|
||||||
|
install_dir: /opt/hellreign
|
||||||
cert_dir: /etc/hellreign-agent/certs
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
config_dir: /etc/hellreign-agent
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Install Docker (if not present)
|
- name: Install Docker (if not present)
|
||||||
@@ -94,6 +123,12 @@ const DockerDeployPlaybook = `---
|
|||||||
state: directory
|
state: directory
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create configuration directory
|
||||||
|
file:
|
||||||
|
path: "{{ config_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
- name: Pull HellreigN Agent image
|
- name: Pull HellreigN Agent image
|
||||||
community.docker.docker_image:
|
community.docker.docker_image:
|
||||||
name: "{{ image }}"
|
name: "{{ image }}"
|
||||||
@@ -103,14 +138,15 @@ const DockerDeployPlaybook = `---
|
|||||||
copy:
|
copy:
|
||||||
content: |
|
content: |
|
||||||
backend_url: "{{ backend_url }}"
|
backend_url: "{{ backend_url }}"
|
||||||
grpc_url: "{{ grpc_url }}"
|
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
|
||||||
label: "{{ agent_label }}"
|
label: "{{ agent_label }}"
|
||||||
registration_token: "{{ agent_token }}"
|
registration_token: "{{ agent_token }}"
|
||||||
cert_dir: "{{ cert_dir }}"
|
cert_dir: "{{ cert_dir }}"
|
||||||
services:
|
services:
|
||||||
- name: system
|
- name: "{{ agent_label }}"
|
||||||
type: journald
|
type: docker
|
||||||
dest: "{{ cert_dir }}/config.yml"
|
path: "{{ container_name }}"
|
||||||
|
dest: "{{ config_dir }}/config.yml"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
|
|
||||||
- name: Create and run HellreigN Agent container
|
- name: Create and run HellreigN Agent container
|
||||||
@@ -121,6 +157,7 @@ const DockerDeployPlaybook = `---
|
|||||||
restart_policy: always
|
restart_policy: always
|
||||||
volumes:
|
volumes:
|
||||||
- "{{ cert_dir }}:/etc/hellreign-agent/certs"
|
- "{{ cert_dir }}:/etc/hellreign-agent/certs"
|
||||||
|
- "{{ config_dir }}/config.yml:/etc/hellreign-agent/config.yml:ro"
|
||||||
env:
|
env:
|
||||||
CONFIG_FILE: /etc/hellreign-agent/certs/config.yml
|
CONFIG_FILE: /etc/hellreign-agent/config.yml
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
package ansible
|
package ansible
|
||||||
|
|
||||||
const BaseInvTemplate = `
|
// This package contains embedded Ansible templates for playbooks and inventory generation.
|
||||||
|
// All templates are defined in playbooks.go and inventory.go.
|
||||||
`
|
|
||||||
|
|||||||
@@ -29,22 +29,33 @@ func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
|
|||||||
grpcPort = "9001"
|
grpcPort = "9001"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grpcHost := os.Getenv("GRPC_SERVER_HOST")
|
||||||
|
if grpcHost == "" {
|
||||||
|
grpcHost = "0.0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
backendURL := os.Getenv("BACKEND_URL")
|
backendURL := os.Getenv("BACKEND_URL")
|
||||||
if backendURL == "" {
|
if backendURL == "" {
|
||||||
backendURL = "http://localhost:8080"
|
backendURL = "http://localhost:8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
giteaURL := os.Getenv("GITEA_RELEASES_URL")
|
||||||
|
if giteaURL == "" {
|
||||||
|
giteaURL = "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download"
|
||||||
|
}
|
||||||
|
|
||||||
exec := ansible.NewExecutor(ansible.ExecutorConfig{
|
exec := ansible.NewExecutor(ansible.ExecutorConfig{
|
||||||
WorkDir: workDir,
|
WorkDir: workDir,
|
||||||
GRPCServerHost: "0.0.0.0", // TODO: make configurable
|
GRPCServerHost: grpcHost,
|
||||||
GRPCServerPort: grpcPort,
|
GRPCServerPort: grpcPort,
|
||||||
BackendURL: backendURL,
|
BackendURL: backendURL,
|
||||||
|
GiteaReleasesURL: giteaURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Write playbooks on init
|
// Write playbooks on init
|
||||||
if err := exec.WriteAllPlaybooks(); err != nil {
|
if err := exec.WriteAllPlaybooks(); err != nil {
|
||||||
// Log but don't fail - playbooks can be written later
|
// Log the error - deployment will fail later if playbooks can't be written
|
||||||
_ = err
|
fmt.Fprintf(os.Stderr, "WARNING: failed to write Ansible playbooks: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentDeployGroup{
|
return &AgentDeployGroup{
|
||||||
@@ -72,6 +83,48 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate auth credentials for each server
|
||||||
|
for i, server := range req.Servers {
|
||||||
|
switch server.AuthMethod {
|
||||||
|
case repository.AuthMethodKey:
|
||||||
|
if server.SSHKey == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("server %d (%s): sshKey is required when authMethod is 'key'", i, server.IP),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case repository.AuthMethodPassword:
|
||||||
|
if server.Password == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("server %d (%s): password is required when authMethod is 'password'", i, server.IP),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("server %d (%s): invalid authMethod %q, expected 'key' or 'password'", i, server.IP, server.AuthMethod),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight check: verify community.docker collection is available for docker deployments
|
||||||
|
needsDockerCollection := false
|
||||||
|
for _, server := range req.Servers {
|
||||||
|
if server.DeployType == repository.DeployTypeDocker {
|
||||||
|
needsDockerCollection = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needsDockerCollection {
|
||||||
|
if err := adg.executor.CheckDockerCollection(); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("Docker deployment requires 'community.docker' Ansible collection: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create work directory
|
// Create work directory
|
||||||
workDir := adg.executor.WorkDir()
|
workDir := adg.executor.WorkDir()
|
||||||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
@@ -123,6 +176,8 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
|||||||
|
|
||||||
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
|
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
|
||||||
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
|
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
|
||||||
|
// Rollback: delete the token we just created
|
||||||
|
_ = adg.Repo.DeleteRegistrationToken(token)
|
||||||
results = append(results, repository.DeployResult{
|
results = append(results, repository.DeployResult{
|
||||||
IP: server.IP,
|
IP: server.IP,
|
||||||
AgentLabel: server.AgentLabel,
|
AgentLabel: server.AgentLabel,
|
||||||
@@ -136,10 +191,14 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
|||||||
// Run Ansible playbook for this server
|
// Run Ansible playbook for this server
|
||||||
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
|
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
|
||||||
|
|
||||||
// Clean up inventory file
|
// Clean up inventory file (log error but don't fail deployment)
|
||||||
os.Remove(inventoryPath)
|
if cleanupErr := os.Remove(inventoryPath); cleanupErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARNING: failed to remove inventory file %s: %v\n", inventoryPath, cleanupErr)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Rollback: delete the token since deployment failed
|
||||||
|
_ = adg.Repo.DeleteRegistrationToken(token)
|
||||||
results = append(results, repository.DeployResult{
|
results = append(results, repository.DeployResult{
|
||||||
IP: server.IP,
|
IP: server.IP,
|
||||||
AgentLabel: server.AgentLabel,
|
AgentLabel: server.AgentLabel,
|
||||||
@@ -155,6 +214,8 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
|||||||
if len(deployResults) > 0 && !deployResults[0].Success {
|
if len(deployResults) > 0 && !deployResults[0].Success {
|
||||||
success = false
|
success = false
|
||||||
errMsg = deployResults[0].Stderr
|
errMsg = deployResults[0].Stderr
|
||||||
|
// Rollback: delete the token since ansible playbook reported failure
|
||||||
|
_ = adg.Repo.DeleteRegistrationToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, repository.DeployResult{
|
results = append(results, repository.DeployResult{
|
||||||
|
|||||||
@@ -299,6 +299,12 @@ func (r *Repository) MarkRegistrationTokenUsed(token string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteRegistrationToken deletes a registration token (used for rollback on deployment failure).
|
||||||
|
func (r *Repository) DeleteRegistrationToken(token string) error {
|
||||||
|
_, err := r.DB.Exec(`DELETE FROM registration_tokens WHERE token = ?`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ActivateToken activates a user by token value.
|
// ActivateToken activates a user by token value.
|
||||||
func (r *Repository) ActivateToken(token string) error {
|
func (r *Repository) ActivateToken(token string) error {
|
||||||
result, err := r.DB.Exec(
|
result, err := r.DB.Exec(
|
||||||
|
|||||||
Reference in New Issue
Block a user