init repo

This commit is contained in:
rmanach 2025-04-02 10:20:05 +02:00
commit 04e14928f3
13 changed files with 706 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
map.json
hmdeploy

146
.golangci.yml Normal file
View File

@ -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

8
Makefile Normal file
View File

@ -0,0 +1,8 @@
run: lint
go run main.go
build: lint
go build -o hmdeploy main.go
lint:
golangci-lint run ./...

8
connection/connection.go Normal file
View File

@ -0,0 +1,8 @@
package connection
type IConnection interface {
Execute(string) (string, error)
CopyFile(src, dest string) error
Close() error
}

View File

@ -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
}

6
deployers/commons.go Normal file
View File

@ -0,0 +1,6 @@
package deployers
type IDeployer interface {
Deploy() error
Close() error
}

96
deployers/swarm.go Normal file
View File

@ -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
}

46
docker/client.go Normal file
View File

@ -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
}

16
go.mod Normal file
View File

@ -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
)

20
go.sum Normal file
View File

@ -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=

75
main.go Normal file
View File

@ -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)
}
}

36
models/hm.go Normal file
View File

@ -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
}

114
models/project.go Normal file
View File

@ -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
}