commit 04e14928f34fa86646427b8e882358057324a7b3 Author: rmanach Date: Wed Apr 2 10:20:05 2025 +0200 init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc924e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +map.json + +hmdeploy \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6bb3835 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,146 @@ +linters-settings: + depguard: + rules: + Main: + files: + - $all + - '!$test' + allow: + - $gostd + - gitea.thegux.fr + - github.com + Test: + files: + - $test + allow: + - $gostd + - gitea.thegux.fr + - github.com + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + gci: + sections: + - "standard" + - "default" + - "blank" + - "dot" + # - "alias" + - "prefix(gitea.thegux.fr)" + goconst: + min-len: 2 + min-occurrences: 2 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - ifElseChain + - octalLiteral + # - whyNoLint + - wrapperFunc + gocyclo: + min-complexity: 15 + goimports: + local-prefixes: gitea.thegux.fr + mnd: + checks: + - argument + - case + - condition + - return + govet: + disable: + - fieldalignment + lll: + line-length: 200 + misspell: + locale: US + nolintlint: + allow-unused: false # report any unused nolint directives + require-explanation: false # don't require an explanation for nolint directives + require-specific: false # don't require nolint directives to be specific about which linter is being skipped + errcheck: + check-blank: true + exclude-functions: + - '(*github.com/gin-gonic/gin.Error).SetType' + - '(*github.com/gin-gonic/gin.Context).Error' + +linters: + disable-all: true + enable: + - bodyclose + - depguard + - dogsled + - dupl + - errcheck + - exhaustive + - exportloopref + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - gofmt + - goimports + # - mnd + - goprintffuncname + - gosec + - gosimple + - govet + # - inamedparam + - ineffassign + - lll + - misspell + - nakedret + - noctx + - nolintlint + # - perfsprint + - rowserrcheck + # - sloglint + - staticcheck + - stylecheck + - typecheck + - unconvert + - unparam + - unused + - whitespace + # - gochecknoglobals # too many global in ds9 + + # don't enable: + # - asciicheck + # - scopelint + # - gocognit + # - godot + # - godox + # - goerr113 + # - interfacer + # - maligned + # - nestif + # - prealloc + # - testpackage + # - revive + # - wsl + +# issues: +# Excluding configuration per-path, per-linter, per-text and per-source +# fix: true + +issues: + exclude-rules: + - path: '(.+)_test\.go' + linters: + - funlen + - goconst + - dupl + exclude-dirs: + - .. + +run: + timeout: 5m diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..04e4029 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +run: lint + go run main.go + +build: lint + go build -o hmdeploy main.go + +lint: + golangci-lint run ./... \ No newline at end of file diff --git a/connection/connection.go b/connection/connection.go new file mode 100644 index 0000000..005f495 --- /dev/null +++ b/connection/connection.go @@ -0,0 +1,8 @@ +package connection + +type IConnection interface { + Execute(string) (string, error) + CopyFile(src, dest string) error + + Close() error +} diff --git a/connection/ssh_connection.go b/connection/ssh_connection.go new file mode 100644 index 0000000..c93a892 --- /dev/null +++ b/connection/ssh_connection.go @@ -0,0 +1,132 @@ +package connection + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + + "github.com/rs/zerolog/log" + "golang.org/x/crypto/ssh" +) + +type SSHConn struct { + addr string + client *ssh.Client +} + +var _ IConnection = (*SSHConn)(nil) + +func NewSSHConn(addr, user string, port int, privkey string) (SSHConn, error) { + var newconn SSHConn + + sshAddr := addr + ":" + strconv.Itoa(port) + newconn.addr = sshAddr + + conn, err := net.Dial("tcp", sshAddr) + if err != nil { + log.Err(err).Str("addr", addr).Msg("unable to dial ssh addr") + return newconn, err + } + + c, err := os.ReadFile(privkey) + if err != nil { + log.Err(err).Str("private key", privkey).Msg("unable to read ssh private key") + return newconn, err + } + + sshPrivKey, err := ssh.ParsePrivateKey(c) + if err != nil { + log.Err(err).Str("private key", privkey).Msg("unable to parse ssh private key") + return newconn, err + } + + sshConfig := ssh.ClientConfig{ + User: user, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(sshPrivKey), + }, + } + + sshConn, chNewChannel, chReq, err := ssh.NewClientConn(conn, sshAddr, &sshConfig) + if err != nil { + log.Err(err).Str("addr", sshAddr).Msg("unable to establish a new connection to the swarm") + return newconn, err + } + + sshClient := ssh.NewClient(sshConn, chNewChannel, chReq) + newconn.client = sshClient + + log.Info().Str("addr", addr).Int("port", port).Msg("ssh connection sucessfully initialized") + return newconn, nil +} + +func (c *SSHConn) Close() error { + return c.client.Close() +} + +func (c *SSHConn) CopyFile(src, dest string) error { + sshSession, err := c.client.NewSession() + if err != nil { + log.Err(err).Str("addr", c.addr).Msg("unable to open an ssh session") + return err + } + defer sshSession.Close() + + fileInfo, err := os.Stat(src) + if err != nil { + log.Err(err).Str("file", src).Msg("unable to stat scp source file") + return err + } + + file, err := os.Open(src) + if err != nil { + log.Err(err).Str("file", src).Msg("unable to open scp source file") + return err + } + defer file.Close() + + go func() { + w, _ := sshSession.StdinPipe() + defer w.Close() + + fmt.Fprintf(w, "C0644 %d %s\n", fileInfo.Size(), filepath.Base(dest)) + + if _, err := io.Copy(w, file); err != nil { + log.Err(err).Str("src", src).Str("dest", dest).Msg("unable to scp src to dest") + return + } + + fmt.Fprint(w, "\x00") + }() + + if err := sshSession.Run(fmt.Sprintf("scp -t %s", dest)); err != nil { + log.Err(err).Str("addr", c.addr).Str("dest", dest).Msg("unable to run scp command") + return err + } + + log.Info().Str("src", src).Str("dest", dest).Msg("file successfully uploaded") + return nil +} + +func (c *SSHConn) Execute(cmd string) (string, error) { + sshSession, err := c.client.NewSession() + if err != nil { + log.Err(err).Str("addr", c.addr).Msg("unable to open an ssh session") + return "", err + } + defer sshSession.Close() + + var buf bytes.Buffer + sshSession.Stdout = &buf + if err := sshSession.Run(cmd); err != nil { + log.Err(err).Str("addr", c.addr).Str("command", cmd).Msg("unable to execute an ssh command") + return "", err + } + + return "", nil +} diff --git a/deployers/commons.go b/deployers/commons.go new file mode 100644 index 0000000..e0dc9bb --- /dev/null +++ b/deployers/commons.go @@ -0,0 +1,6 @@ +package deployers + +type IDeployer interface { + Deploy() error + Close() error +} diff --git a/deployers/swarm.go b/deployers/swarm.go new file mode 100644 index 0000000..eec64fe --- /dev/null +++ b/deployers/swarm.go @@ -0,0 +1,96 @@ +package deployers + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/rs/zerolog/log" + + "gitea.thegux.fr/hmdeploy/connection" + "gitea.thegux.fr/hmdeploy/docker" + "gitea.thegux.fr/hmdeploy/models" +) + +type SwarmDeployer struct { + ctx context.Context + fnCancel context.CancelFunc + + conn connection.IConnection + dcli docker.IClient + + project *models.Project +} + +var _ IDeployer = (*SwarmDeployer)(nil) + +func NewSwarmDeployer(ctx context.Context, dockerClient docker.IClient, netInfo *models.HMNetInfo, project *models.Project) (SwarmDeployer, error) { + var sm SwarmDeployer + + conn, err := connection.NewSSHConn(netInfo.IP.String(), netInfo.SSH.User, netInfo.SSH.Port, netInfo.SSH.PrivKey) + if err != nil { + return sm, err + } + + ctxChild, fnCancel := context.WithCancel(ctx) + + sm.ctx = ctxChild + sm.fnCancel = fnCancel + + sm.conn = &conn + sm.dcli = dockerClient + sm.project = project + + return sm, nil +} + +func (sd *SwarmDeployer) Close() error { + return sd.conn.Close() +} + +func (sd *SwarmDeployer) clean() (err error) { + _, err = sd.conn.Execute(fmt.Sprintf("rm -f %s %s", models.ComposeFile, models.EnvFile)) + return +} + +func (sd *SwarmDeployer) Deploy() error { + defer sd.clean() + + if sd.project.ImageName != "" { + tarFile, err := sd.dcli.Save(sd.project.ImageName, sd.project.Dir) + if err != nil { + return err + } + + defer os.Remove(tarFile) + + tarFileBase := filepath.Base(tarFile) + if err := sd.conn.CopyFile(tarFile, tarFileBase); err != nil { + return err + } + + if _, err := sd.conn.Execute(fmt.Sprintf("docker load -i %s && rm %s", tarFileBase, tarFileBase)); err != nil { + return err + } + } + + if envFilePath := sd.project.Deps.EnvFile; envFilePath != "" { + envFileBase := filepath.Base(envFilePath) + if err := sd.conn.CopyFile(filepath.Join(sd.project.Dir, envFileBase), envFileBase); err != nil { + return err + } + } + + composeFileBase := filepath.Base(sd.project.Deps.ComposeFile) + if err := sd.conn.CopyFile(filepath.Join(sd.project.Dir, composeFileBase), composeFileBase); err != nil { + return err + } + + if _, err := sd.conn.Execute(fmt.Sprintf("docker stack deploy -c %s %s", composeFileBase, sd.project.Name)); err != nil { + return err + } + + log.Info().Msg("project deployed successfully on swarm") + return nil +} diff --git a/docker/client.go b/docker/client.go new file mode 100644 index 0000000..81c8cb5 --- /dev/null +++ b/docker/client.go @@ -0,0 +1,46 @@ +package docker + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/rs/zerolog/log" +) + +type IClient interface { + Save(imageName, dest string) (string, error) +} + +type Client struct{} + +var _ IClient = (*Client)(nil) + +func NewClient() Client { + return Client{} +} + +func (c *Client) Save(imageName, dest string) (string, error) { + destInfo, err := os.Stat(dest) + if err != nil { + log.Err(err).Str("dest", dest).Msg("unable to stat dest directory") + return "", err + } + + if !destInfo.IsDir() { + log.Err(err).Str("dest", dest).Msg("dest directory must be a directory") + } + + tarFile := fmt.Sprintf("%s.tar", imageName) + + cmd := exec.Command("docker", "save", "-o", tarFile, imageName) + cmd.Dir = dest + if _, err := cmd.Output(); err != nil { + log.Err(err).Str("image", imageName).Str("dest", dest).Msg("unable to save image into tar") + return dest, err + } + + log.Info().Str("image", imageName).Str("dest", dest).Msg("image successfully saved") + return filepath.Join(dest, tarFile), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..67e1091 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module gitea.thegux.fr/hmdeploy + +go 1.23.0 + +toolchain go1.23.7 + +require ( + github.com/rs/zerolog v1.34.0 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + golang.org/x/sys v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..15b8154 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c44a3ed --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "os" + "os/signal" + "path" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "gitea.thegux.fr/hmdeploy/deployers" + "gitea.thegux.fr/hmdeploy/docker" + "gitea.thegux.fr/hmdeploy/models" +) + +const HMDEPLOY_DIRNAME = ".homeserver" +const NETWORK_FILENAME = "map.json" + +var HOME_PATH = os.Getenv("HOME") + +func initLogger() { + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr}) +} + +func main() { + ctx, stop := signal.NotifyContext( + context.Background(), + os.Interrupt, + os.Kill, + ) + defer stop() + + initLogger() + log.Info().Msg("hmdeploy started") + + projectDir := flag.String("path", ".", "define the .homeserver project root dir") + flag.Parse() + + hmmap_path := path.Join(HOME_PATH, HMDEPLOY_DIRNAME, NETWORK_FILENAME) + c, err := os.ReadFile(hmmap_path) + if err != nil { + log.Fatal().Err(err).Str("conf", hmmap_path).Msg("unable to load configuration") + } + + var hmmap models.HMMap + if err := json.Unmarshal(c, &hmmap); err != nil { + log.Fatal().Err(err).Str("conf", hmmap_path).Msg("unable to parse configuration") + } + log.Info().Str("conf", hmmap_path).Msg("hmmap load successfully") + + swarmNet := hmmap.GetSwarmNetInfo() + if swarmNet == nil { + log.Fatal().Err(err).Msg("unable to get swarm net info, does not exist") + } + + project, err := models.ProjectFromDir(*projectDir) + if err != nil { + os.Exit(1) + } + + dcli := docker.NewClient() + + sd, err := deployers.NewSwarmDeployer(ctx, &dcli, swarmNet, &project) + if err != nil { + os.Exit(1) + } + + if err := sd.Deploy(); err != nil { + os.Exit(1) + } +} diff --git a/models/hm.go b/models/hm.go new file mode 100644 index 0000000..d5e2305 --- /dev/null +++ b/models/hm.go @@ -0,0 +1,36 @@ +package models + +import ( + "net" + + "github.com/rs/zerolog/log" +) + +type HMNetInfo struct { + IP net.IP `json:"ip"` + WebURL string `json:"web_url,omitempty"` + SSH struct { + User string `json:"user"` + PrivKey string `json:"privkey"` + Port int `json:"port"` + } `json:"ssh,omitempty"` +} + +type HMVM map[string]*HMNetInfo +type HMLXC map[string]*HMNetInfo + +type HMMap struct { + *HMNetInfo + VM HMVM `json:"vm,omitempty"` + LXC HMLXC `json:"lxc,omitempty"` +} + +func (hm *HMMap) GetSwarmNetInfo() *HMNetInfo { + data, ok := hm.VM["swarm"] + if !ok { + log.Error().Msg("unable to get swarm net info, check your configuration") + return nil + } + + return data +} diff --git a/models/project.go b/models/project.go new file mode 100644 index 0000000..253fbdf --- /dev/null +++ b/models/project.go @@ -0,0 +1,114 @@ +package models + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/rs/zerolog/log" +) + +const ( + MainDir string = ".homeserver" + + ComposeFile string = "docker-compose.deploy.yml" + EnvFile = ".env" + NginxFile = "nginx.conf" + + ConfFile = "hmdeploy.json" +) + +var ( + ErrInvalidEnvFile = errors.New("unable to stat .env file") + ErrInvalidComposeFile = errors.New("unable to stat compose file") + ErrInvalidNginxFile = errors.New("unable to stat nginx file") +) + +func getFileInfo(baseDir, filePath string) (fs.FileInfo, error) { + var fInf fs.FileInfo + + filePath = filepath.Clean(filePath) + filePath = filepath.Join(baseDir, filePath) + + composePath, err := filepath.Abs(filePath) + if err != nil { + return fInf, fmt.Errorf("%w, %v", ErrInvalidComposeFile, err) + } + + fInf, err = os.Stat(composePath) + if err != nil { + return fInf, fmt.Errorf("%w, %v", ErrInvalidComposeFile, err) + } + + return fInf, nil +} + +type Project struct { + Name string `json:"name"` + Dir string + ImageName string `json:"image"` + Deps struct { + EnvFile string `json:"env"` + EnvFileInfo fs.FileInfo + + ComposeFile string `json:"compose"` + ComposeFileInfo fs.FileInfo + + NginxFile string `json:"nginx"` + NginxFileInfo fs.FileInfo + } `json:"dependencies"` +} + +func (p *Project) validate() error { + cfs, err := getFileInfo(p.Dir, p.Deps.ComposeFile) + if err != nil { + return fmt.Errorf("%w, %v", ErrInvalidComposeFile, err) + } + p.Deps.ComposeFileInfo = cfs + + if p.Deps.EnvFile != "" { + efs, err := getFileInfo(p.Dir, p.Deps.EnvFile) + if err != nil { + return fmt.Errorf("%w, %v", ErrInvalidEnvFile, err) + } + p.Deps.EnvFileInfo = efs + } else { + log.Warn().Msg("no .env file provided, hoping one is set elsewhere...") + } + + nfs, err := getFileInfo(p.Dir, p.Deps.EnvFile) + if err != nil { + return fmt.Errorf("%w, %v", ErrInvalidNginxFile, err) + } + p.Deps.NginxFileInfo = nfs + + return nil +} + +func ProjectFromDir(dir string) (Project, error) { + var p Project + + dir = filepath.Join(dir, MainDir) + p.Dir = dir + + content, err := os.ReadFile(filepath.Join(dir, ConfFile)) + if err != nil { + log.Err(err).Str("dir", dir).Str("conf", ConfFile).Msg("unable to read conf file") + return p, err + } + + if err := json.Unmarshal(content, &p); err != nil { + log.Err(err).Str("dir", dir).Str("conf", ConfFile).Msg("unable to parse conf file") + return p, err + } + + if err := p.validate(); err != nil { + log.Err(err).Str("dir", dir).Msg("unable to validate project") + return p, err + } + + return p, nil +}