diff --git a/.gitignore b/.gitignore index dc924e4..323be71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ map.json -hmdeploy \ No newline at end of file +hmdeploy +hmdeploy-linux* \ No newline at end of file diff --git a/deployers/commons.go b/deployers/commons.go index 08d051b..d5b90fa 100644 --- a/deployers/commons.go +++ b/deployers/commons.go @@ -10,7 +10,10 @@ import ( "github.com/rs/zerolog/log" ) -var ErrContextDone = errors.New("unable to execute, context done") +var ( + ErrContextDone = errors.New("unable to execute, context done") + ErrEmptyArchive = errors.New("no file to add to the archive") +) type IDeployer interface { Type() DeployerType @@ -48,7 +51,8 @@ type deployer struct { //nolint:govet // ll type_ DeployerType errFlag error - project *models.Project + project *models.Project + archivePath string } func newDeployer(ctx context.Context, type_ DeployerType, project *models.Project) *deployer { diff --git a/deployers/nginx.go b/deployers/nginx.go index 073981f..f761cf1 100644 --- a/deployers/nginx.go +++ b/deployers/nginx.go @@ -3,9 +3,12 @@ package deployers import ( "context" "fmt" + "os" + "path/filepath" "gitea.thegux.fr/hmdeploy/connection" "gitea.thegux.fr/hmdeploy/models" + "gitea.thegux.fr/hmdeploy/utils" "github.com/rs/zerolog/log" ) @@ -40,12 +43,28 @@ func NewNginxDeployer( return nd, nil } +func (nd NginxDeployer) getConfPath() string { + return nd.project.GetNginxConfPath() +} + +func (nd NginxDeployer) getAssetsPath() string { + return nd.project.GetNginxAssetsPath() +} + func (nd *NginxDeployer) close() error { return nd.conn.Close() } func (nd *NginxDeployer) clean() (err error) { - _, err = nd.conn.Execute("rm -f " + nd.project.Name + ".conf") + if err = os.Remove(nd.archivePath); err != nil { + log.Err(err).Str("archive", nd.archivePath).Msg("unable to clean local nginx archive file") + } + + cmd := "rm -rf nginx.conf build/ *.tar.gz" + if ap := nd.getAssetsPath(); ap != "" { + cmd += " " + filepath.Base(nd.getAssetsPath()) + } + _, err = nd.conn.Execute(cmd) return } @@ -75,20 +94,36 @@ func (nd *NginxDeployer) Build() error { default: } - nginxConf := nd.project.Name + ".conf" + log.Info().Msg("building nginx archive for deployment...") - log.Info().Str("nginx", nginxConf).Msg("transferring nginx conf...") + filesToArchive := []string{} + if ap := nd.getAssetsPath(); ap != "" { + filesToArchive = append(filesToArchive, ap) + } + if cp := nd.getConfPath(); cp != "" { + filesToArchive = append(filesToArchive, cp) + } - if err := nd.conn.CopyFile(nd.project.Deps.NginxFile, nginxConf); err != nil { - nd.setDone(err) + if len(filesToArchive) == 0 { + return ErrEmptyArchive + } + + archivePath, err := utils.CreateArchive( + nd.project.Dir, + fmt.Sprintf("%s-%s", nd.project.Name, "nginx"), + filesToArchive..., + ) + if err != nil { return err } - log.Info().Str("nginx", nginxConf).Msg("nginx conf transferred with success") + nd.archivePath = archivePath + + log.Info().Str("archive", archivePath).Msg("nginx archive built") return nil } -func (nd *NginxDeployer) Deploy() (err error) { +func (nd *NginxDeployer) Deploy() error { nd.processing.Store(true) defer nd.processing.Store(false) @@ -99,25 +134,55 @@ func (nd *NginxDeployer) Deploy() (err error) { default: } - nginxConf := nd.project.Name + ".conf" - - log.Info().Str("nginx", nginxConf).Msg("deploying nginx conf...") - - _, err = nd.conn.Execute( - fmt.Sprintf( - "cp %s /etc/nginx/sites-available && ln -sf /etc/nginx/sites-available/%s /etc/nginx/sites-enabled/%s", - nginxConf, - nginxConf, - nginxConf, - ), - ) - nd.setDone(err) - - if err == nil { - log.Info().Str("nginx", nginxConf).Msg("nginx conf successfully deployed") + archiveDestPath := filepath.Base(nd.archivePath) + if err := nd.conn.CopyFile(nd.archivePath, archiveDestPath); err != nil { + nd.setDone(err) + return err } - return err + if _, err := nd.conn.Execute(fmt.Sprintf("tar xzvf %s", archiveDestPath)); err != nil { + nd.setDone(err) + return err + } + + if ap := nd.getAssetsPath(); ap != "" { + log.Info().Msg("deploying nginx assets...") + + if _, err := nd.conn.Execute( + fmt.Sprintf( + "rm -rf /var/www/static/%s/* && mkdir -p /var/www/static/%s && mv %s/* /var/www/static/%s", + nd.project.Name, + nd.project.Name, + filepath.Base(nd.getAssetsPath()), + nd.project.Name, + ), + ); err != nil { + nd.setDone(err) + return err + } + } + + if cp := nd.getConfPath(); cp != "" { + nginxConf := nd.project.Name + ".conf" + log.Info().Str("nginx", nginxConf).Msg("deploying nginx conf...") + + if _, err := nd.conn.Execute( + fmt.Sprintf( + "mv %s /etc/nginx/sites-available/%s && ln -sf /etc/nginx/sites-available/%s /etc/nginx/sites-enabled/%s", + filepath.Base(cp), + nginxConf, + nginxConf, + nginxConf, + ), + ); err != nil { + nd.setDone(err) + return err + } + } + + log.Info().Msg("nginx project successfully deployed") + nd.setDone(nil) + return nil } func (nd *NginxDeployer) Destroy() (err error) { diff --git a/deployers/swarm.go b/deployers/swarm.go index 30995a9..432cf0c 100644 --- a/deployers/swarm.go +++ b/deployers/swarm.go @@ -19,10 +19,9 @@ var ErrSwarmDeployerNoArchive = errors.New("no archive found to be deployed") // SwarmDeployer handles the deployment of a Docker service on the swarm instance. type SwarmDeployer struct { *deployer - conn connection.IConnection - dloc docker.IClient - drem *docker.RemoteClient - archivePath string + conn connection.IConnection + dloc docker.IClient + drem *docker.RemoteClient } var _ IDeployer = (*SwarmDeployer)(nil) @@ -54,6 +53,14 @@ func NewSwarmDeployer( return sd, nil } +func (sd SwarmDeployer) getComposePath() string { + return sd.project.GetComposePath() +} + +func (sd SwarmDeployer) getEnvPath() string { + return sd.project.GetEnvPath() +} + func (sd *SwarmDeployer) close() error { return sd.conn.Close() } @@ -119,7 +126,7 @@ func (sd *SwarmDeployer) Build() error { log.Info().Str("image", tarFile).Msg("image transferred with success") } - if envFilePath := sd.project.Deps.EnvFile; envFilePath != "" { + if envFilePath := sd.getEnvPath(); envFilePath != "" { filesToArchive = append( filesToArchive, envFilePath, @@ -129,7 +136,7 @@ func (sd *SwarmDeployer) Build() error { filesToArchive = append( filesToArchive, - sd.project.Deps.ComposeFile, + sd.getComposePath(), ) archivePath, err := utils.CreateArchive( @@ -171,9 +178,6 @@ func (sd *SwarmDeployer) Deploy() error { } archiveDestPath := filepath.Base(sd.archivePath) - log.Info(). - Str("archive", sd.archivePath). - Msg("archive built with success, tranferring to swarm for deployment...") if err := sd.conn.CopyFile(sd.archivePath, archiveDestPath); err != nil { sd.setDone(err) return err @@ -185,7 +189,7 @@ func (sd *SwarmDeployer) Deploy() error { } log.Info().Str("project", sd.project.Name).Msg("deploying swarm project...") - composeFileBase := filepath.Base(sd.project.Deps.ComposeFile) + composeFileBase := filepath.Base(sd.getComposePath()) if err := sd.drem.DeployStack(sd.ctx, sd.project.Name, composeFileBase, docker.WithCheckState()); err != nil { sd.setDone(err) return err diff --git a/docker/client.go b/docker/client.go index 4a8f207..caa222a 100644 --- a/docker/client.go +++ b/docker/client.go @@ -18,7 +18,7 @@ import ( const ( stateTickDuration = 4 * time.Second - defaultStateTimeout = 30 * time.Second + defaultStateTimeout = 10 * time.Minute ) var ( diff --git a/hmdeploy.example.json b/hmdeploy.example.json new file mode 100644 index 0000000..c4f1133 --- /dev/null +++ b/hmdeploy.example.json @@ -0,0 +1,13 @@ +{ + "name": "my-api", + "dependencies": { + "swarm": { + "env": ".env", + "compose": "docker-compose.deploy.yml" + }, + "nginx": { + "conf": "nginx.conf", + "assets": "assets/" + } + } +} \ No newline at end of file diff --git a/main.go b/main.go index 36b7d3c..d11fe19 100644 --- a/main.go +++ b/main.go @@ -136,10 +136,13 @@ func (d *Deployers) generateTasksTree() scheduler.Tasks { func (d *Deployers) waitForCompletion(s *scheduler.Scheduler) error { var wg sync.WaitGroup - deps := []deployers.IDeployer{d.sd} + deps := []deployers.IDeployer{} if d.nd != nil { deps = append(deps, d.nd) } + if d.sd != nil { + deps = append(deps, d.sd) + } for idx := range deps { if d := deps[idx]; d != nil { @@ -238,14 +241,40 @@ func loadHMMap() (models.HMMap, error) { return hmmap, nil } +func initSwarmDeployer( + ctx context.Context, + project *models.Project, + swarmNet *models.HMNetInfo, + fnCancel context.CancelFunc, +) (deployers.SwarmDeployer, error) { + dloc := docker.NewLocalClient() + drem, err := docker.NewRemoteClient(swarmNet) + if err != nil { + return deployers.SwarmDeployer{}, err + } + + sd, err := deployers.NewSwarmDeployer(ctx, project, swarmNet, &dloc, &drem) + if err != nil { + return deployers.SwarmDeployer{}, fmt.Errorf( + "%w, unable to init swarm deployer, err=%v", + ErrDeployerInit, + err, + ) + } + + if fnCancel != nil { + sd.SetCancellationFunc(fnCancel) + } + + return sd, nil +} + // initDeployers instanciates from `Project` and `HMMap` needed deployers and returns them. // // You can provide as an optional arg: // - WithGlobalCancellation(fnCancel context.CancelFunc): close the global context, notifying all deployers to stop // - WithNoSwarm(): disable Swarm deployment // - WithNoNginx(): disable Nginx deployment -// -//nolint:funlen // not that so much... func initDeployers( ctx context.Context, hmmap *models.HMMap, @@ -261,34 +290,21 @@ func initDeployers( destroy: opt.destroy, } - swarmNet := hmmap.GetSwarmNetInfo() - if swarmNet == nil { - return deps, fmt.Errorf("%w, swarm net info does not exist", ErrNetInfoNotFound) - } + if !opt.noSwarm && project.GetComposePath() != "" { + swarmNet := hmmap.GetSwarmNetInfo() + if swarmNet == nil { + return deps, fmt.Errorf("%w, swarm net info does not exist", ErrNetInfoNotFound) + } - dloc := docker.NewLocalClient() - drem, err := docker.NewRemoteClient(swarmNet) - if err != nil { - return deps, err - } - - if !opt.noSwarm { - sd, err := deployers.NewSwarmDeployer(ctx, project, swarmNet, &dloc, &drem) + sd, err := initSwarmDeployer(ctx, project, swarmNet, opt.fnCancel) if err != nil { - return deps, fmt.Errorf( - "%w, unable to init swarm deployer, err=%v", - ErrDeployerInit, - err, - ) + return deps, err } - deps.sd = &sd - if opt.fnCancel != nil { - sd.SetCancellationFunc(opt.fnCancel) - } + deps.sd = &sd } - if !opt.noNginx && project.Deps.NginxFile != "" { + if !opt.noNginx && project.GetNginxConfPath() != "" { nginxNet := hmmap.GetNginxNetInfo() if nginxNet == nil { return deps, fmt.Errorf("%w, nginx net info does not exist", ErrNetInfoNotFound) diff --git a/models/project.go b/models/project.go index 4936fe2..25f3f9f 100644 --- a/models/project.go +++ b/models/project.go @@ -22,10 +22,26 @@ const ( var ErrProjectConfFile = errors.New("project error") -func getFilepath(baseDir, filePath string) (string, error) { - filePath = filepath.Join(baseDir, filePath) +type filepathOption struct { + isDir bool +} + +type fnFilepathOption func(*filepathOption) + +func IsDir() fnFilepathOption { + return func(fo *filepathOption) { + fo.isDir = true + } +} + +func getFilepath(baseDir, filePath string, options ...fnFilepathOption) (string, error) { + var opts filepathOption + for _, opt := range options { + opt(&opts) + } if !filepath.IsAbs(filePath) { + filePath = filepath.Join(baseDir, filePath) filePath, err := filepath.Abs(filePath) //nolint: govet if err != nil { return filePath, fmt.Errorf( @@ -47,7 +63,7 @@ func getFilepath(baseDir, filePath string) (string, error) { ) } - if fileInfo.IsDir() { + if fileInfo.IsDir() && !opts.isDir { return filePath, fmt.Errorf( "%w, file=%s, err=%s", ErrProjectConfFile, @@ -56,6 +72,15 @@ func getFilepath(baseDir, filePath string) (string, error) { ) } + if !fileInfo.IsDir() && opts.isDir { + return filePath, fmt.Errorf( + "%w, file=%s, err=%s", + ErrProjectConfFile, + filePath, + "must be a dir", + ) + } + return filePath, nil } @@ -64,43 +89,73 @@ type Project struct { Name string `json:"name"` Dir string Deps struct { - EnvFile string `json:"env"` - ComposeFile string `json:"compose"` - NginxFile string `json:"nginx"` + Swarm struct { + EnvFile string `json:"env"` + ComposeFile string `json:"compose"` + } `json:"swarm"` + Nginx struct { + Conf string `json:"conf"` + Assets string `json:"assets"` + } } `json:"dependencies"` ImageNames []string `json:"images"` } func (p *Project) validate() error { - cpath, err := getFilepath(p.Dir, p.Deps.ComposeFile) - if err != nil { - return err - } - p.Deps.ComposeFile = cpath - - if p.Deps.EnvFile != "" { - epath, err := getFilepath(p.Dir, p.Deps.EnvFile) + if compf := p.Deps.Swarm.ComposeFile; compf != "" { + cpath, err := getFilepath(p.Dir, compf) if err != nil { return err } - p.Deps.EnvFile = epath + + p.Deps.Swarm.ComposeFile = cpath } else { - log.Warn().Msg("no .env file provided, hoping one it's set elsewhere...") + log.Warn().Msg("no docker-compose file provided, Swarm deployment discards") } - if p.Deps.NginxFile != "" { - npath, err := getFilepath(p.Dir, p.Deps.NginxFile) + if env := p.Deps.Swarm.EnvFile; env != "" { + epath, err := getFilepath(p.Dir, env) if err != nil { return err } - p.Deps.NginxFile = npath - } else { - log.Warn().Msg("no Nginx conf file provided, Nginx deployment discarded") + p.Deps.Swarm.EnvFile = epath + } + + if conf := p.Deps.Nginx.Conf; conf != "" { + npath, err := getFilepath(p.Dir, conf) + if err != nil { + return err + } + p.Deps.Nginx.Conf = npath + } + + if assets := p.Deps.Nginx.Assets; assets != "" { + apath, err := getFilepath(p.Dir, assets, IsDir()) + if err != nil { + return err + } + p.Deps.Nginx.Assets = apath } return nil } +func (p Project) GetComposePath() string { + return p.Deps.Swarm.ComposeFile +} + +func (p Project) GetEnvPath() string { + return p.Deps.Swarm.EnvFile +} + +func (p Project) GetNginxConfPath() string { + return p.Deps.Nginx.Conf +} + +func (p Project) GetNginxAssetsPath() string { + return p.Deps.Nginx.Assets +} + // ProjectFromDir instantiates a new project from a directory path. // // The directory path must refers to the path including the `.homeserver` dir not diff --git a/utils/utils.go b/utils/utils.go index a21599b..0a072d0 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -17,25 +17,75 @@ import ( const confirmChar = "Y" -func addToArchive(tw *tar.Writer, filename string) error { - file, err := os.Open(filename) +// addToArchive adds a file or directory (recursively) to the tar writer. +// The baseName is the desired root name in the archive. +// +//nolint:funlen // it's ok +func addToArchive(tw *tar.Writer, filename, baseName, basePath string) error { + // resolve the absolute path to eliminate relative components like ../ + absPath, err := filepath.Abs(filename) + if err != nil { + return fmt.Errorf("unable to resolve absolute path for %s: %v", filename, err) + } + + file, err := os.Open(absPath) if err != nil { return err } - defer file.Close() //nolint: errcheck // defered + defer file.Close() //nolint: errcheck // deferred info, err := file.Stat() if err != nil { return err } - header, err := tar.FileInfoHeader(info, info.Name()) + // compute the relative path within the archive + // start with baseName and append the relative path from basePath + relPath, err := filepath.Rel(basePath, absPath) + if err != nil { + return fmt.Errorf("unable to compute relative path for %s: %v", absPath, err) + } + + // combine baseName with the relative path + if relPath == "." { + relPath = baseName + } else { + relPath = filepath.Join(baseName, relPath) + } + + relPath = filepath.ToSlash(relPath) + + if info.IsDir() { + header, err := tar.FileInfoHeader(info, "") //nolint:govet // shadow ok + if err != nil { + return err + } + header.Name = relPath + if err := tw.WriteHeader(header); err != nil { //nolint:govet // shadow ok + return err + } + + entries, err := os.ReadDir(absPath) + if err != nil { + return err + } + + for _, entry := range entries { + entryPath := filepath.Join(absPath, entry.Name()) + if err := addToArchive(tw, entryPath, baseName, basePath); err != nil { + return err + } + } + + return nil + } + + header, err := tar.FileInfoHeader(info, "") if err != nil { return err } - header.Name = filepath.Base(file.Name()) - - if err = tw.WriteHeader(header); err != nil { + header.Name = relPath + if err := tw.WriteHeader(header); err != nil { //nolint:govet // shadow ok return err } @@ -43,7 +93,8 @@ func addToArchive(tw *tar.Writer, filename string) error { return err } -// CreateArchive creates a gzip tar archive in the `destDir` path including `files`. +// CreateArchive creates a gzip tar archive in the `destDir` path including `files` and returns +// the generated archive path. func CreateArchive(destDir, name string, files ...string) (string, error) { now := time.Now().UTC() archivePath := filepath.Join( @@ -55,16 +106,22 @@ func CreateArchive(destDir, name string, files ...string) (string, error) { if err != nil { return "", fmt.Errorf("unable to create archive=%s, err=%v", archivePath, err) } - defer file.Close() //nolint: errcheck // defered + defer file.Close() //nolint: errcheck // deferred gw := gzip.NewWriter(file) - defer gw.Close() //nolint: errcheck // defered + defer gw.Close() //nolint: errcheck // deferred tw := tar.NewWriter(gw) - defer tw.Close() //nolint: errcheck // defered + defer tw.Close() //nolint: errcheck // deferred for _, f := range files { - if err := addToArchive(tw, f); err != nil { + absPath, err := filepath.Abs(f) + if err != nil { + return "", fmt.Errorf("unable to resolve absolute path for %s: %v", f, err) + } + + baseName := filepath.Base(f) + if err := addToArchive(tw, f, baseName, absPath); err != nil { return "", fmt.Errorf( "unable to add file=%s to archive=%s, err=%v", f,