GitRoot
craft your forge, build your project, grow your community freely
1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
2//
3// SPDX-License-Identifier: EUPL-1.2
4
5package exec
6
7import (
8 "bytes"
9 "fmt"
10 "os"
11 "path/filepath"
12 "strings"
13
14 "github.com/povsister/scp"
15 "github.com/samber/oops"
16 pluginLib "gitroot.dev/libs/golang/plugin/model"
17 "gitroot.dev/server/configuration"
18 "gitroot.dev/server/logger"
19 "golang.org/x/crypto/ssh"
20)
21
22type Ssh struct {
23 currentHost int
24 logger *logger.Logger
25 conf configuration.SshConf
26 userManager needUser
27}
28
29func NewSsh(log *logger.Logger, conf configuration.SshConf, userManager needUser) *Ssh {
30 return &Ssh{
31 currentHost: 0,
32 logger: log.NewSubLogger("ssh"),
33 conf: conf,
34 userManager: userManager,
35 }
36}
37
38func (e *Ssh) name() string {
39 return "ssh"
40}
41
42func (e *Ssh) prepareTempDir(dir string) error {
43 if len(e.conf.Hosts) == 0 {
44 return oops.Errorf("no host")
45 }
46 if err := prepareJobRunner(dir); err != nil {
47 return oops.Wrapf(err, "can't prepareJobRunner")
48 }
49 return nil
50}
51
52func (e *Ssh) exec(dir string, exe pluginLib.Exec, projectPluginCacheDir string) (*pluginLib.ExecStatus, error) {
53 //TODO manage cache ! how ?
54
55 host := e.conf.Hosts[e.currentHost]
56 e.currentHost++
57 if e.currentHost >= len(e.conf.Hosts) {
58 e.currentHost = 0
59 }
60
61 signer := e.userManager.RootCommiter().Signer.Signer()
62 auth := []ssh.AuthMethod{
63 ssh.PublicKeys(signer),
64 }
65
66 serverKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(host.PublicKey))
67 if len(e.conf.Hosts) == 0 {
68 return nil, oops.Wrapf(err, "can't parse server public key")
69 }
70
71 config := &ssh.ClientConfig{
72 User: host.User,
73 Auth: auth,
74 HostKeyCallback: ssh.FixedHostKey(serverKey),
75 }
76
77 addr := fmt.Sprintf("%s:%d", host.Address, host.Port)
78 e.logger.Info("connexion SSH", logger.NewLoggerPair("user", host.User), logger.NewLoggerPair("addr", addr))
79
80 conn, err := ssh.Dial("tcp", addr, config)
81 if err != nil {
82 return nil, oops.With("user", host.User, "addr", addr).Wrapf(err, "can't connect")
83 }
84 defer conn.Close()
85
86 e.logger.Info("scp dir", logger.NewLoggerPair("dir", dir))
87 scpClient, err := scp.NewClientFromExistingSSH(conn, &scp.ClientOption{})
88 if err != nil {
89 return nil, oops.Wrapf(err, "can't open scp conn")
90 }
91 defer scpClient.Close()
92
93 err = scpClient.CopyDirToRemote(dir, dir, &scp.DirTransferOption{})
94 if err != nil {
95 return nil, oops.Wrapf(err, "can't scp dir")
96 }
97
98 execs, err := cmdToExec(exe, true)
99 if err != nil {
100 return nil, oops.Wrapf(err, "cmdToExec")
101 }
102
103 var logs bytes.Buffer
104
105 session, err := conn.NewSession()
106 if err != nil {
107 return nil, oops.Wrapf(err, "can't open ssh conn")
108 }
109
110 for _, env := range exe.Env {
111 keyVal := strings.Split(env, "=")
112 if len(keyVal) >= 2 {
113 session.Setenv(keyVal[0], keyVal[1])
114 }
115 }
116 session.Stdout = &logs
117 session.Stderr = &logs
118
119 cmdToRun := fmt.Sprintf("cd %s/%s && chmod +x executor && cd ../%s && %s", dir, JOB_CONTEXT_DIR, APP_DIR, strings.Join(execs, " "))
120 err = session.Run(cmdToRun)
121 if err != nil {
122 return nil, oops.Wrapf(err, "can't run over ssh")
123 }
124
125 logStr := logs.Bytes()
126 status, err := parseJobLog(logStr)
127 if err != nil {
128 return nil, oops.Wrapf(err, "can't parse json %s", logStr)
129 }
130
131 logsDir := filepath.Join(dir, JOB_CONTEXT_DIR, LOGS_DIR)
132 os.MkdirAll(logsDir, os.ModePerm)
133 err = scpClient.CopyDirFromRemote(logsDir, logsDir, &scp.DirTransferOption{})
134 if err != nil {
135 return nil, oops.Wrapf(err, "can't scp logs dir to local")
136 }
137
138 artifactsDir := filepath.Join(dir, JOB_CONTEXT_DIR, ARTIFACTS_DIR)
139 os.MkdirAll(artifactsDir, os.ModePerm)
140 err = scpClient.CopyDirFromRemote(artifactsDir, artifactsDir, &scp.DirTransferOption{})
141 if err != nil {
142 return nil, oops.Wrapf(err, "can't scp artifacts dir to local")
143 }
144
145 return status, nil
146}