diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0f8e90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +*~ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7da6a91 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +sudo: false + +language: go + +go: + - 1.6.x + - 1.7.x + - tip + +branches: + only: + - master + - develop + +os: + - linux + +matrix: + fast_finish: true + allow_failures: + - go: tip + +before_install: + - make deps + +script: + - make test + - make bin + - ./init-exporter --version diff --git a/Dockerfile.systemd b/Dockerfile.systemd new file mode 100644 index 0000000..e201461 --- /dev/null +++ b/Dockerfile.systemd @@ -0,0 +1,22 @@ +FROM centos:centos7 + +ENV GOPATH /root +ENV TARGET /root/src/github.com/funbox/init-exporter + +RUN yum install -y http://release.yum.kaos.io/i386/kaos-repo-1.2.0-0.el6.noarch.rpm +RUN yum clean all && yum -y update + +RUN yum -y install make go + +COPY . $TARGET +RUN ls $TARGET -al +RUN cd $TARGET && make all && make install + +RUN useradd service +RUN mkdir -p /var/local/init-exporter/helpers && mkdir -p /var/log/init-exporter + +COPY ./testdata /root + +RUN yum clean all && rm -rf /tmp/* + +WORKDIR /root diff --git a/Dockerfile.upstart b/Dockerfile.upstart new file mode 100644 index 0000000..e4eec8d --- /dev/null +++ b/Dockerfile.upstart @@ -0,0 +1,22 @@ +FROM centos:centos6 + +ENV GOPATH /root +ENV TARGET /root/src/github.com/funbox/init-exporter + +RUN yum install -y http://release.yum.kaos.io/i386/kaos-repo-1.2.0-0.el6.noarch.rpm +RUN yum clean all && yum -y update + +RUN yum -y install go + +COPY . $TARGET +RUN ls $TARGET -al +RUN cd $TARGET && make all && make install + +RUN useradd service +RUN mkdir -p /var/local/init-exporter/helpers && mkdir -p /var/log/init-exporter + +COPY ./testdata /root + +RUN yum clean all && rm -rf /tmp/* + +WORKDIR /root diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dd018fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016 FB Group + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc0ba50 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +######################################################################################## + +DESTDIR?= +PREFIX?=/usr + +######################################################################################## + +.PHONY = all clean install uninstall deps deps-glide test + +######################################################################################## + +all: bin + +deps: + go get -v pkg.re/check.v1 + go get -v pkg.re/essentialkaos/ek.v6 + go get -v pkg.re/essentialkaos/go-simpleyaml.v1 + go get -v pkg.re/yaml.v2 + +deps-glide: + glide install + +bin: + go build init-exporter.go + +fmt: + find . -name "*.go" -exec gofmt -s -w {} \; + +test: + go test ./... -covermode=count + +install: + mkdir -p $(DESTDIR)$(PREFIX)/bin + cp init-exporter $(DESTDIR)$(PREFIX)/sbin/ + cp common/init-exporter.conf $(DESTDIR)/etc/ + +uninstall: + rm -f $(DESTDIR)$(PREFIX)/sbin/init-exporter + rm -rf $(DESTDIR)/etc/init-exporter.conf + +clean: + rm -f init-exporter + +upstart-playground: + docker build -f ./Dockerfile.upstart -t upstart-playground . && docker run -ti --rm=true upstart-playground /bin/bash + +systemd-playground: + docker build -f ./Dockerfile.systemd -t systemd-playground . && docker run -ti --rm=true systemd-playground /bin/bash diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..3ff4d29 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,444 @@ +package cli + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "fmt" + "os" + "runtime" + + "pkg.re/essentialkaos/ek.v6/arg" + "pkg.re/essentialkaos/ek.v6/env" + "pkg.re/essentialkaos/ek.v6/fmtc" + "pkg.re/essentialkaos/ek.v6/fsutil" + "pkg.re/essentialkaos/ek.v6/knf" + "pkg.re/essentialkaos/ek.v6/log" + "pkg.re/essentialkaos/ek.v6/system" + "pkg.re/essentialkaos/ek.v6/usage" + + "github.com/funbox/init-exporter/export" + "github.com/funbox/init-exporter/procfile" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +// App props +const ( + APP = "init-exporter" + VER = "0.6.0" + DESC = "Utility for exporting services described by Procfile to init system" +) + +// Supported arguments list +const ( + ARG_PROCFILE = "p:procfile" + ARG_APP_NAME = "n:appname" + ARG_DRY_START = "d:dry-start" + ARG_UNINSTALL = "u:unistall" + ARG_FORMAT = "f:format" + ARG_NO_COLORS = "nc:no-colors" + ARG_HELP = "h:help" + ARG_VERSION = "v:version" +) + +// Config properies list +const ( + MAIN_RUN_USER = "main:run-user" + MAIN_RUN_GROUP = "main:run-group" + MAIN_PREFIX = "main:prefix" + PATHS_WORKING_DIR = "paths:working-dir" + PATHS_HELPER_DIR = "paths:helper-dir" + PATHS_SYSTEMD_DIR = "paths:systemd-dir" + PATHS_UPSTART_DIR = "paths:upstart-dir" + LIMITS_NPROC = "limits:nproc" + LIMITS_NOFILE = "limits:nofile" + LOG_ENABLED = "log:enabled" + LOG_DIR = "log:dir" + LOG_FILE = "log:file" + LOG_PERMS = "log:perms" + LOG_LEVEL = "log:level" +) + +const ( + // FORMAT_UPSTART contains name for upstart exporting format + FORMAT_UPSTART = "upstart" + // FORMAT_SYSTEMD contains name for systemd exporting format + FORMAT_SYSTEMD = "systemd" +) + +// CONFIG_FILE contains path to config file +const CONFIG_FILE = "/etc/init-exporter.conf" + +// ////////////////////////////////////////////////////////////////////////////////// // + +var argMap = arg.Map{ + ARG_APP_NAME: {}, + ARG_PROCFILE: {}, + ARG_DRY_START: {Type: arg.BOOL}, + ARG_UNINSTALL: {Type: arg.BOOL, Alias: "c:clear"}, + ARG_FORMAT: {}, + ARG_NO_COLORS: {Type: arg.BOOL}, + ARG_HELP: {Type: arg.BOOL}, + ARG_VERSION: {Type: arg.BOOL}, +} + +var user *system.User + +// ////////////////////////////////////////////////////////////////////////////////// // + +func Init() { + runtime.GOMAXPROCS(1) + + args, errs := arg.Parse(argMap) + + if len(errs) != 0 { + fmt.Println("Error while arguments parsing:") + + for _, err := range errs { + fmt.Printf(" %v\n", err) + } + + os.Exit(1) + } + + if arg.GetB(ARG_NO_COLORS) { + fmtc.DisableColors = true + } + + if arg.GetB(ARG_VERSION) { + showAbout() + return + } + + if arg.GetB(ARG_HELP) { + showUsage() + return + } + + if len(args) == 0 && !arg.Has(ARG_APP_NAME) { + showUsage() + return + } + + checkForRoot() + checkArguments() + loadConfig() + validateConfig() + setupLogger() + + switch { + case len(args) == 0: + startProcessing(arg.GetS(ARG_APP_NAME)) + default: + startProcessing(args[0]) + } +} + +// checkForRoot check superuser privileges +func checkForRoot() { + var err error + + user, err = system.CurrentUser() + + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + + if !user.IsRoot() { + fmt.Println("This utility must have superuser privileges (root)") + os.Exit(1) + } +} + +// checkArguments check given arguments +func checkArguments() { + if !arg.GetB(ARG_UNINSTALL) { + proc := arg.GetS(ARG_PROCFILE) + + switch { + case fsutil.IsExist(proc) == false: + printErrorAndExit("Procfile %s does not exist", proc) + + case fsutil.IsReadable(proc) == false: + printErrorAndExit("Procfile %s is not readable", proc) + + case fsutil.IsNonEmpty(proc) == false: + printErrorAndExit("Procfile %s is empty", proc) + } + } +} + +// loadConfig check config path and load config +func loadConfig() { + var err error + + switch { + case !fsutil.IsExist(CONFIG_FILE): + printErrorAndExit("Config %s is not exist", CONFIG_FILE) + + case !fsutil.IsReadable(CONFIG_FILE): + printErrorAndExit("Config %s is not readable", CONFIG_FILE) + + case !fsutil.IsNonEmpty(CONFIG_FILE): + printErrorAndExit("Config %s is empty", CONFIG_FILE) + } + + err = knf.Global(CONFIG_FILE) + + if err != nil { + printErrorAndExit(err.Error()) + } +} + +// validateConfig validate config values +func validateConfig() { + var permsChecker = func(config *knf.Config, prop string, value interface{}) error { + if !fsutil.CheckPerms(value.(string), config.GetS(prop)) { + switch value.(string) { + case "DRX": + return fmt.Errorf("Property %s must be path to readable directory", prop) + case "DWX": + return fmt.Errorf("Property %s must be path to writable directory", prop) + case "DRWX": + return fmt.Errorf("Property %s must be path to writable/readable directory", prop) + case "FR": + return fmt.Errorf("Property %s must be path to readable file", prop) + } + } + + return nil + } + + var userChecker = func(config *knf.Config, prop string, value interface{}) error { + if !system.IsUserExist(knf.GetS(prop)) { + return fmt.Errorf("Property %s contains user which not exist on this system", prop) + } + + return nil + } + + var groupChecker = func(config *knf.Config, prop string, value interface{}) error { + if !system.IsGroupExist(knf.GetS(prop)) { + return fmt.Errorf("Property %s contains group which not exist on this system", prop) + } + + return nil + } + + validators := []*knf.Validator{ + {MAIN_RUN_USER, knf.Empty, nil}, + {MAIN_RUN_GROUP, knf.Empty, nil}, + {PATHS_WORKING_DIR, knf.Empty, nil}, + {PATHS_HELPER_DIR, knf.Empty, nil}, + {PATHS_SYSTEMD_DIR, knf.Empty, nil}, + {PATHS_UPSTART_DIR, knf.Empty, nil}, + {LIMITS_NOFILE, knf.Empty, nil}, + {LIMITS_NPROC, knf.Empty, nil}, + + {LIMITS_NOFILE, knf.Less, 0}, + {LIMITS_NPROC, knf.Less, 0}, + + {MAIN_RUN_USER, userChecker, nil}, + {MAIN_RUN_GROUP, groupChecker, nil}, + + {PATHS_WORKING_DIR, permsChecker, "DRWX"}, + {PATHS_HELPER_DIR, permsChecker, "DRWX"}, + } + + if knf.GetB(LOG_ENABLED, true) { + validators = append(validators, + &knf.Validator{LOG_DIR, knf.Empty, nil}, + &knf.Validator{LOG_FILE, knf.Empty, nil}, + &knf.Validator{LOG_DIR, permsChecker, "DWX"}, + ) + } + + errs := knf.Validate(validators) + + if len(errs) != 0 { + fmt.Println("Errors while config validation:") + + for _, err := range errs { + fmt.Printf(" %v\n", err) + } + + os.Exit(1) + } +} + +// setupLogger configure logging subsystem +func setupLogger() { + if !knf.GetB(LOG_ENABLED, true) { + log.Set(os.DevNull, 0) + return + } + + log.Set(knf.GetS(LOG_FILE), knf.GetM(LOG_PERMS, 0644)) + log.MinLevel(knf.GetS(LOG_LEVEL, "info")) +} + +// startProcessing start processing +func startProcessing(appName string) { + if !arg.GetB(ARG_UNINSTALL) { + installApplication(appName) + } else { + uninstallApplication(appName) + } +} + +// installApplication install application to init system +func installApplication(appName string) { + fullAppName := knf.GetS(MAIN_PREFIX) + appName + + app, err := procfile.Read( + arg.GetS(ARG_PROCFILE), + &procfile.Config{ + Name: fullAppName, + User: knf.GetS(MAIN_RUN_USER), + Group: knf.GetS(MAIN_RUN_GROUP), + WorkingDir: knf.GetS(PATHS_WORKING_DIR), + LimitFile: knf.GetI(LIMITS_NOFILE, 0), + LimitProc: knf.GetI(LIMITS_NPROC, 0), + }, + ) + + if err != nil { + printErrorAndExit(err.Error()) + } + + if arg.GetB(ARG_DRY_START) { + os.Exit(0) + } + + err = getExporter().Install(app) + + if err == nil { + log.Aux("User %s (%d) installed service %s", user.RealName, user.RealUID, app.Name) + } else { + printErrorAndExit(err.Error()) + } +} + +// uninstallApplication uninstall application from init system +func uninstallApplication(appName string) { + fullAppName := knf.GetS(MAIN_PREFIX) + appName + app := &procfile.Application{Name: fullAppName} + + err := getExporter().Uninstall(app) + + if err == nil { + log.Aux("User %s (%d) uninstalled service %s", user.RealName, user.RealUID, app.Name) + } else { + printErrorAndExit(err.Error()) + } +} + +// checkProviderTargetDir check permissions on target dir +func checkProviderTargetDir(dir string) error { + if !fsutil.CheckPerms("DRWX", dir) { + return fmt.Errorf("This utility require read/write access to directory %s", dir) + } + + return nil +} + +// getExporter create and configure exporter and return it +func getExporter() *export.Exporter { + providerName, err := detectProvider(arg.GetS(ARG_FORMAT)) + + if err != nil { + printErrorAndExit(err.Error()) + } + + var provider export.Provider + + exportConfig := &export.Config{HelperDir: knf.GetS(PATHS_HELPER_DIR)} + + switch providerName { + case FORMAT_UPSTART: + exportConfig.TargetDir = knf.GetS(PATHS_UPSTART_DIR) + provider = export.NewUpstart() + case FORMAT_SYSTEMD: + exportConfig.TargetDir = knf.GetS(PATHS_SYSTEMD_DIR) + provider = export.NewSystemd() + } + + err = checkProviderTargetDir(exportConfig.TargetDir) + + if err != nil { + printErrorAndExit(err.Error()) + } + + return export.NewExporter(exportConfig, provider) +} + +// detectProvider try to detect provider +func detectProvider(format string) (string, error) { + switch { + case format == FORMAT_SYSTEMD: + return FORMAT_SYSTEMD, nil + case format == FORMAT_UPSTART: + return FORMAT_UPSTART, nil + case os.Args[0] == "systemd-exporter": + return FORMAT_SYSTEMD, nil + case os.Args[0] == "upstart-exporter": + return FORMAT_UPSTART, nil + case env.Which("systemctl") != "": + return FORMAT_SYSTEMD, nil + case env.Which("initctl") != "": + return FORMAT_UPSTART, nil + default: + return "", fmt.Errorf("Can't find init provider") + } +} + +// printErrorAndExit print error mesage and exit with exit code 1 +func printErrorAndExit(message string, a ...interface{}) { + log.Crit(message) + fmt.Printf(message+"\n", a...) + os.Exit(1) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// showUsage print usage info to console +func showUsage() { + usage.Breadcrumbs = true + + info := usage.NewInfo("", "app-name") + + info.AddOption(ARG_PROCFILE, "Path to procfile", "file") + info.AddOption(ARG_DRY_START, "Dry start {s-}(don't export anything, just parse and test procfile){!}") + info.AddOption(ARG_UNINSTALL, "Remove scripts and helpers for a particular application") + info.AddOption(ARG_FORMAT, "Format of generated configs", "upstart|systemd") + info.AddOption(ARG_NO_COLORS, "Disable colors in output") + info.AddOption(ARG_HELP, "Show this help message") + info.AddOption(ARG_VERSION, "Show version") + + info.AddExample("-p ./myprocfile -f systemd myapp", "Export given procfile to systemd as myapp") + info.AddExample("-u -f systemd myapp", "Uninstall myapp from systemd") + + info.AddExample("-p ./myprocfile -f upstart myapp", "Export given procfile to upstart as myapp") + info.AddExample("-u -f upstart myapp", "Uninstall myapp from upstart") + + info.Render() +} + +// showAbout print version info to console +func showAbout() { + about := &usage.About{ + App: APP, + Version: VER, + Desc: DESC, + Year: 2006, + Owner: "FB Group", + License: "MIT License", + } + + about.Render() +} diff --git a/common/init-exporter.conf b/common/init-exporter.conf new file mode 100644 index 0000000..3aeea10 --- /dev/null +++ b/common/init-exporter.conf @@ -0,0 +1,51 @@ +# Default configuration for init-exporter + +[main] + + # Default run user + run-user: service + + # Default run group + run-group: service + + # Prefix used for exported units and helpers + prefix: fb- + +[paths] + + # Working dir + working-dir: /tmp + + # Path to directory with helpers + helper-dir: /var/local/init-exporter/helpers + + # Path to directory with systemd configs + systemd-dir: /etc/systemd/system + + # Path to directory with upstart configs + upstart-dir: /etc/init + +[limits] + + # Number of Processes + nproc: 10240 + + # Number of File Descriptors + nofile: 10240 + +[log] + + # Enable or disable logging here + enabled: true + + # Log file directory + dir: /var/log/init-exporter + + # Path to log file + file: {log:dir}/init-exporter.log + + # Default log file permissions + perms: 0644 + + # Minimal log level (debug/info/warn/error/crit) + level: info diff --git a/common/init-exporter.spec b/common/init-exporter.spec new file mode 100644 index 0000000..da7d83c --- /dev/null +++ b/common/init-exporter.spec @@ -0,0 +1,109 @@ +############################################################################### + +# rpmbuilder:relative-pack true + +############################################################################### + +%define _posixroot / +%define _root /root +%define _bin /bin +%define _sbin /sbin +%define _srv /srv +%define _home /home +%define _lib32 %{_posixroot}lib +%define _lib64 %{_posixroot}lib64 +%define _libdir32 %{_prefix}%{_lib32} +%define _libdir64 %{_prefix}%{_lib64} +%define _logdir %{_localstatedir}/log +%define _rundir %{_localstatedir}/run +%define _lockdir %{_localstatedir}/lock/subsys +%define _cachedir %{_localstatedir}/cache +%define _spooldir %{_localstatedir}/spool +%define _crondir %{_sysconfdir}/cron.d +%define _loc_prefix %{_prefix}/local +%define _loc_exec_prefix %{_loc_prefix} +%define _loc_bindir %{_loc_exec_prefix}/bin +%define _loc_libdir %{_loc_exec_prefix}/%{_lib} +%define _loc_libdir32 %{_loc_exec_prefix}/%{_lib32} +%define _loc_libdir64 %{_loc_exec_prefix}/%{_lib64} +%define _loc_libexecdir %{_loc_exec_prefix}/libexec +%define _loc_sbindir %{_loc_exec_prefix}/sbin +%define _loc_bindir %{_loc_exec_prefix}/bin +%define _loc_datarootdir %{_loc_prefix}/share +%define _loc_includedir %{_loc_prefix}/include +%define _rpmstatedir %{_sharedstatedir}/rpm-state +%define _pkgconfigdir %{_libdir}/pkgconfig + +############################################################################### + +%define debug_package %{nil} + +############################################################################### + +Summary: Utility for exporting services described by Procfile to init system +Name: init-exporter +Version: 0.6.0 +Release: 0%{?dist} +Group: Development/Tools +License: MIT +URL: https://github.com/funbox/init-exporter + +Source0: %{name}-%{version}.tar.gz + +BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) + +BuildRequires: golang >= 1.5 + +Provides: upstart-exporter = %{version}-%{release} +Provides: systemd-exporter = %{version}-%{release} + +Provides: %{name} = %{version}-%{release} + +############################################################################### + +%description +Utility for exporting services described by Procfile to init system. + +############################################################################### + +%prep +%setup -q + +%build +export GOPATH=$(pwd) +go build -o %{name} src/github.com/funbox/%{name}/%{name}.go + +%install +rm -rf %{buildroot} + +install -dm 755 %{buildroot}%{_bindir} +install -dm 755 %{buildroot}%{_sysconfdir} +install -dm 755 %{buildroot}%{_logdir}/%{name} +install -dm 755 %{buildroot}%{_loc_prefix}/%{name} +install -dm 755 %{buildroot}%{_localstatedir}/local/%{name}/helpers + +install -pm 755 %{name} %{buildroot}%{_bindir}/ + +ln -sf %{_bindir}/%{name} %{buildroot}%{_bindir}/upstart-exporter +ln -sf %{_bindir}/%{name} %{buildroot}%{_bindir}/systemd-exporter + +install -pm 755 src/github.com/funbox/%{name}/common/%{name}.conf \ + %{buildroot}%{_sysconfdir}/ + +%clean +rm -rf %{buildroot} + +############################################################################### + +%files +%defattr(-,root,root,-) +%config(noreplace) %{_sysconfdir}/%{name}.conf +%dir %{_logdir}/%{name} +%dir %{_localstatedir}/local/%{name}/helpers +%{_bindir}/*-exporter + +############################################################################### + +%changelog +* Thu Feb 2 2017 Anton Novojilov - 0.6.0-0 +- Initial build diff --git a/export/export_test.go b/export/export_test.go new file mode 100644 index 0000000..49a7d45 --- /dev/null +++ b/export/export_test.go @@ -0,0 +1,440 @@ +package export + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/funbox/init-exporter/procfile" + + "pkg.re/essentialkaos/ek.v6/fsutil" + "pkg.re/essentialkaos/ek.v6/log" + + . "pkg.re/check.v1" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +func Test(t *testing.T) { TestingT(t) } + +type ExportSuite struct { + HelperDir string + TargetDir string +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +var _ = Suite(&ExportSuite{}) + +// ////////////////////////////////////////////////////////////////////////////////// // + +func (s *ExportSuite) SetUpSuite(c *C) { + // Disable logging + log.Set(os.DevNull, 0) +} + +func (s *ExportSuite) TestUpstartExport(c *C) { + helperDir := c.MkDir() + targetDir := c.MkDir() + + config := &Config{ + HelperDir: helperDir, + TargetDir: targetDir, + DisableAutoStart: true, + } + + exporter := NewExporter(config, NewUpstart()) + + c.Assert(exporter, NotNil) + + app := createTestApp(targetDir, helperDir) + + err := exporter.Install(app) + + c.Assert(err, IsNil) + + c.Assert(fsutil.IsExist(targetDir+"/test_application.conf"), Equals, true) + c.Assert(fsutil.IsRegular(targetDir+"/test_application.conf"), Equals, true) + c.Assert(fsutil.IsNonEmpty(targetDir+"/test_application.conf"), Equals, true) + + c.Assert(fsutil.IsExist(targetDir+"/test_application-service1.conf"), Equals, true) + c.Assert(fsutil.IsRegular(targetDir+"/test_application-service1.conf"), Equals, true) + c.Assert(fsutil.IsNonEmpty(targetDir+"/test_application-service1.conf"), Equals, true) + + c.Assert(fsutil.IsExist(targetDir+"/test_application-service2.conf"), Equals, true) + c.Assert(fsutil.IsRegular(targetDir+"/test_application-service2.conf"), Equals, true) + c.Assert(fsutil.IsNonEmpty(targetDir+"/test_application-service2.conf"), Equals, true) + + c.Assert(fsutil.IsExist(helperDir+"/test_application-service1.sh"), Equals, true) + c.Assert(fsutil.IsRegular(helperDir+"/test_application-service1.sh"), Equals, true) + c.Assert(fsutil.IsNonEmpty(helperDir+"/test_application-service1.sh"), Equals, true) + + c.Assert(fsutil.IsExist(helperDir+"/test_application-service2.sh"), Equals, true) + c.Assert(fsutil.IsRegular(helperDir+"/test_application-service2.sh"), Equals, true) + c.Assert(fsutil.IsNonEmpty(helperDir+"/test_application-service2.sh"), Equals, true) + + appUnitData, err := ioutil.ReadFile(targetDir + "/test_application.conf") + + c.Assert(err, IsNil) + c.Assert(appUnitData, NotNil) + + service1UnitData, err := ioutil.ReadFile(targetDir + "/test_application-service1.conf") + + c.Assert(err, IsNil) + c.Assert(service1UnitData, NotNil) + + service2UnitData, err := ioutil.ReadFile(targetDir + "/test_application-service2.conf") + + c.Assert(err, IsNil) + c.Assert(service2UnitData, NotNil) + + service1HelperData, err := ioutil.ReadFile(helperDir + "/test_application-service1.sh") + + c.Assert(err, IsNil) + c.Assert(service1HelperData, NotNil) + + service2HelperData, err := ioutil.ReadFile(helperDir + "/test_application-service2.sh") + + c.Assert(err, IsNil) + c.Assert(service2HelperData, NotNil) + + appUnit := strings.Split(string(appUnitData), "\n") + service1Unit := strings.Split(string(service1UnitData), "\n") + service2Unit := strings.Split(string(service2UnitData), "\n") + service1Helper := strings.Split(string(service1HelperData), "\n") + service2Helper := strings.Split(string(service2HelperData), "\n") + + c.Assert(appUnit, HasLen, 16) + c.Assert(service1Unit, HasLen, 21) + c.Assert(service2Unit, HasLen, 21) + c.Assert(service1Helper, HasLen, 8) + c.Assert(service2Helper, HasLen, 8) + + c.Assert(appUnit[2:], DeepEquals, + []string{ + "start on [3]", + "stop on [3]", + "", + "pre-start script", + "", + "bash << \"EOF\"", + " mkdir -p /var/log/test_application", + " chown -R service /var/log/test_application", + " chgrp -R service /var/log/test_application", + " chmod -R g+w /var/log/test_application", + "EOF", + "", + "end script", ""}, + ) + + c.Assert(service1Unit[2:], DeepEquals, + []string{ + "start on [3]", + "stop on [3]", + "", + "respawn", + "respawn limit 15 25", + "", + "kill timeout 10", + "", + "limit nofile 1024 1024", + "", + "", + "script", + " touch /var/log/test_application/service1.log", + " chown service /var/log/test_application/service1.log", + " chgrp service /var/log/test_application/service1.log", + " chmod g+w /var/log/test_application/service1.log", + fmt.Sprintf(" exec sudo -u service /bin/bash %s/test_application-service1.sh >> /srv/service/service1-dir/custom.log >> /var/log/test_application/service1.log 2>&1", helperDir), + "end script", ""}, + ) + + c.Assert(service2Unit[2:], DeepEquals, + []string{ + "start on [3]", + "stop on [3]", + "", + "respawn", + "", + "", + "kill timeout 0", + "", + "limit nofile 4096 4096", + "limit nproc 4096 4096", + "", + "script", + " touch /var/log/test_application/service2.log", + " chown service /var/log/test_application/service2.log", + " chgrp service /var/log/test_application/service2.log", + " chmod g+w /var/log/test_application/service2.log", + fmt.Sprintf(" exec sudo -u service /bin/bash %s/test_application-service2.sh >> /var/log/test_application/service2.log 2>&1", helperDir), + "end script", ""}, + ) + + c.Assert(service1Helper[4:], DeepEquals, + []string{ + "[[ -r /etc/profile.d/rbenv.sh ]] && source /etc/profile.d/rbenv.sh", "", + "cd /srv/service/service1-dir && exec STAGING=true /bin/echo service1", + ""}, + ) + + c.Assert(service2Helper[4:], DeepEquals, + []string{ + "[[ -r /etc/profile.d/rbenv.sh ]] && source /etc/profile.d/rbenv.sh", "", + "cd /srv/service/working-dir && exec /bin/echo service2", + ""}, + ) + + err = exporter.Uninstall(app) + + c.Assert(err, IsNil) + + c.Assert(fsutil.IsExist(targetDir+"/test_application.conf"), Equals, false) + c.Assert(fsutil.IsExist(targetDir+"/test_application-service1.conf"), Equals, false) + c.Assert(fsutil.IsExist(targetDir+"/test_application-service2.conf"), Equals, false) + c.Assert(fsutil.IsExist(helperDir+"/test_application-service1.sh"), Equals, false) + c.Assert(fsutil.IsExist(helperDir+"/test_application-service2.sh"), Equals, false) +} + +func (s *ExportSuite) TestSystemdExport(c *C) { + helperDir := c.MkDir() + targetDir := c.MkDir() + + config := &Config{ + HelperDir: helperDir, + TargetDir: targetDir, + DisableAutoStart: true, + } + + exporter := NewExporter(config, NewSystemd()) + + c.Assert(exporter, NotNil) + + app := createTestApp(targetDir, helperDir) + + err := exporter.Install(app) + + c.Assert(err, IsNil) + + c.Assert(fsutil.IsExist(targetDir+"/test_application.service"), Equals, true) + c.Assert(fsutil.IsRegular(targetDir+"/test_application.service"), Equals, true) + c.Assert(fsutil.IsNonEmpty(targetDir+"/test_application.service"), Equals, true) + + c.Assert(fsutil.IsExist(targetDir+"/test_application-service1.service"), Equals, true) + c.Assert(fsutil.IsRegular(targetDir+"/test_application-service1.service"), Equals, true) + c.Assert(fsutil.IsNonEmpty(targetDir+"/test_application-service1.service"), Equals, true) + + c.Assert(fsutil.IsExist(targetDir+"/test_application-service2.service"), Equals, true) + c.Assert(fsutil.IsRegular(targetDir+"/test_application-service2.service"), Equals, true) + c.Assert(fsutil.IsNonEmpty(targetDir+"/test_application-service2.service"), Equals, true) + + c.Assert(fsutil.IsExist(helperDir+"/test_application-service1.sh"), Equals, true) + c.Assert(fsutil.IsRegular(helperDir+"/test_application-service1.sh"), Equals, true) + c.Assert(fsutil.IsNonEmpty(helperDir+"/test_application-service1.sh"), Equals, true) + + c.Assert(fsutil.IsExist(helperDir+"/test_application-service2.sh"), Equals, true) + c.Assert(fsutil.IsRegular(helperDir+"/test_application-service2.sh"), Equals, true) + c.Assert(fsutil.IsNonEmpty(helperDir+"/test_application-service2.sh"), Equals, true) + + appUnitData, err := ioutil.ReadFile(targetDir + "/test_application.service") + + c.Assert(err, IsNil) + c.Assert(appUnitData, NotNil) + + service1UnitData, err := ioutil.ReadFile(targetDir + "/test_application-service1.service") + + c.Assert(err, IsNil) + c.Assert(service1UnitData, NotNil) + + service2UnitData, err := ioutil.ReadFile(targetDir + "/test_application-service2.service") + + c.Assert(err, IsNil) + c.Assert(service2UnitData, NotNil) + + service1HelperData, err := ioutil.ReadFile(helperDir + "/test_application-service1.sh") + + c.Assert(err, IsNil) + c.Assert(service1HelperData, NotNil) + + service2HelperData, err := ioutil.ReadFile(helperDir + "/test_application-service2.sh") + + c.Assert(err, IsNil) + c.Assert(service2HelperData, NotNil) + + appUnit := strings.Split(string(appUnitData), "\n") + service1Unit := strings.Split(string(service1UnitData), "\n") + service2Unit := strings.Split(string(service2UnitData), "\n") + service1Helper := strings.Split(string(service1HelperData), "\n") + service2Helper := strings.Split(string(service2HelperData), "\n") + + c.Assert(appUnit, HasLen, 22) + c.Assert(service1Unit, HasLen, 29) + c.Assert(service2Unit, HasLen, 29) + c.Assert(service1Helper, HasLen, 8) + c.Assert(service2Helper, HasLen, 8) + + c.Assert(appUnit[2:], DeepEquals, + []string{ + "[Unit]", + "", + "Description=Unit for test_application application", + "After=multi-user.target", + "Wants=test_application-service1.service test_application-service2.service", + "", + "[Service]", + "Type=oneshot", + "RemainAfterExit=true", + "", + "ExecStartPre=/bin/mkdir -p /var/log/test_application", + "ExecStartPre=/bin/chown -R service /var/log/test_application", + "ExecStartPre=/bin/chgrp -R service /var/log/test_application", + "ExecStartPre=/bin/chmod -R g+w /var/log/test_application", + "ExecStart=/bin/echo \"test_application started\"", + "ExecStop=/bin/echo \"test_application stopped\"", + "", + "[Install]", + "WantedBy=multi-user.target", ""}, + ) + + c.Assert(service1Unit[2:], DeepEquals, + []string{ + "[Unit]", + "", + "Description=Unit for service1 service (part of test_application application)", + "PartOf=test_application.service", + "", + "[Service]", + "Type=simple", + "", + "TimeoutStopSec=10", + "Restart=on-failure", + "StartLimitInterval=25", + "StartLimitBurst=15", + "", + "LimitNOFILE=1024", + "", + "", + "ExecStartPre=/bin/touch /var/log/test_application/service1.log", + "ExecStartPre=/bin/chown service /var/log/test_application/service1.log", + "ExecStartPre=/bin/chgrp service /var/log/test_application/service1.log", + "ExecStartPre=/bin/chmod g+w /var/log/test_application/service1.log", + "", + "User=service", + "Group=service", + "WorkingDirectory=/srv/service/service1-dir", + "Environment=STAGING=true", + fmt.Sprintf("ExecStart=/bin/bash %s/test_application-service1.sh >> /srv/service/service1-dir/custom.log >> /var/log/test_application/service1.log 2>&1", helperDir), + ""}, + ) + + c.Assert(service2Unit[2:], DeepEquals, + []string{ + "[Unit]", + "", + "Description=Unit for service2 service (part of test_application application)", + "PartOf=test_application.service", + "", + "[Service]", + "Type=simple", + "", + "TimeoutStopSec=0", + "Restart=on-failure", + "", + "", + "", + "LimitNOFILE=4096", + "LimitNPROC=4096", + "", + "ExecStartPre=/bin/touch /var/log/test_application/service2.log", + "ExecStartPre=/bin/chown service /var/log/test_application/service2.log", + "ExecStartPre=/bin/chgrp service /var/log/test_application/service2.log", + "ExecStartPre=/bin/chmod g+w /var/log/test_application/service2.log", + "", + "User=service", + "Group=service", + "WorkingDirectory=/srv/service/working-dir", + "", + fmt.Sprintf("ExecStart=/bin/bash %s/test_application-service2.sh >> /var/log/test_application/service2.log 2>&1", helperDir), + ""}, + ) + + c.Assert(service1Helper[4:], DeepEquals, + []string{ + "[[ -r /etc/profile.d/rbenv.sh ]] && source /etc/profile.d/rbenv.sh", "", + "exec /bin/echo service1", + ""}, + ) + + c.Assert(service2Helper[4:], DeepEquals, + []string{ + "[[ -r /etc/profile.d/rbenv.sh ]] && source /etc/profile.d/rbenv.sh", "", + "exec /bin/echo service2", + ""}, + ) + + err = exporter.Uninstall(app) + + c.Assert(err, IsNil) + + c.Assert(fsutil.IsExist(targetDir+"/test_application.service"), Equals, false) + c.Assert(fsutil.IsExist(targetDir+"/test_application-service1.service"), Equals, false) + c.Assert(fsutil.IsExist(targetDir+"/test_application-service2.service"), Equals, false) + c.Assert(fsutil.IsExist(helperDir+"/test_application-service1.sh"), Equals, false) + c.Assert(fsutil.IsExist(helperDir+"/test_application-service2.sh"), Equals, false) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +func createTestApp(helperDir, targetDir string) *procfile.Application { + app := &procfile.Application{ + Name: "test_application", + User: "service", + Group: "service", + StartLevel: 3, + StopLevel: 3, + WorkingDir: "/srv/service/working-dir", + ProcVersion: 2, + Services: []*procfile.Service{}, + } + + service1 := &procfile.Service{ + Name: "service1", + Cmd: "/bin/echo service1", + Application: app, + Options: &procfile.ServiceOptions{ + Env: map[string]string{"STAGING": "true"}, + WorkingDir: "/srv/service/service1-dir", + LogPath: "/srv/service/service1-dir/custom.log", + KillTimeout: 10, + Count: 2, + RespawnInterval: 25, + RespawnCount: 15, + IsRespawnEnabled: true, + LimitFile: 1024, + }, + } + + service2 := &procfile.Service{ + Name: "service2", + Cmd: "/bin/echo service2", + Application: app, + Options: &procfile.ServiceOptions{ + WorkingDir: "/srv/service/working-dir", + IsRespawnEnabled: true, + LimitFile: 4096, + LimitProc: 4096, + }, + } + + app.Services = append(app.Services, service1, service2) + + return app +} diff --git a/export/exporter.go b/export/exporter.go new file mode 100644 index 0000000..8bcab9f --- /dev/null +++ b/export/exporter.go @@ -0,0 +1,233 @@ +package export + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "fmt" + "io/ioutil" + "os" + + "pkg.re/essentialkaos/ek.v6/fsutil" + "pkg.re/essentialkaos/ek.v6/log" + "pkg.re/essentialkaos/ek.v6/path" + + "github.com/funbox/init-exporter/procfile" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +type Config struct { + HelperDir string + TargetDir string + DisableAutoStart bool +} + +type Exporter struct { + Provider Provider + Config *Config +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +func NewExporter(config *Config, provider Provider) *Exporter { + return &Exporter{Config: config, Provider: provider} +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// Install install application to init system +func (e *Exporter) Install(app *procfile.Application) error { + var err error + + if e.IsInstalled(app) { + err = e.Uninstall(app) + + if err != nil { + return err + } + } + + err = e.writeAppUnit(app) + + if err != nil { + return err + } + + err = e.writeServicesUnits(app) + + if err != nil { + return err + } + + if !e.Config.DisableAutoStart { + err = e.Provider.EnableService(app.Name) + + if err != nil { + return err + } + + log.Debug("Service %s enabled", app.Name) + } + + return nil +} + +// Uninstall uninstall application from init system +func (e *Exporter) Uninstall(app *procfile.Application) error { + var err error + + if !e.IsInstalled(app) { + return fmt.Errorf("Application %s is not installed", app.Name) + } + + if !e.Config.DisableAutoStart { + err = e.Provider.DisableService(app.Name) + + if err != nil { + return err + } + } + + log.Debug("Service %s disabled", app.Name) + + unitPath := e.unitPath(app.Name) + err = os.Remove(unitPath) + + if err != nil { + return err + } + + log.Debug("Application unit %s deleted", unitPath) + + err = deleteByMask(e.Config.TargetDir, app.Name+"-*") + + if err != nil { + return err + } + + log.Debug("Service units deleted") + + err = deleteByMask(e.Config.HelperDir, app.Name+"-*.sh") + + if err != nil { + return err + } + + log.Debug("Helpers deleted") + + return nil +} + +// IsInstalled return true if app already installed +func (e *Exporter) IsInstalled(app *procfile.Application) bool { + return fsutil.IsExist(e.unitPath(app.Name)) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// writeAppUnit write app init to file +func (e *Exporter) writeAppUnit(app *procfile.Application) error { + unitPath := e.unitPath(app.Name) + data, err := e.Provider.RenderAppTemplate(app) + + if err != nil { + return err + } + + log.Debug("Application unit saved as %s", unitPath) + + err = ioutil.WriteFile(unitPath, []byte(data), 0644) + + return err +} + +// writeAppUnit write services init to files +func (e *Exporter) writeServicesUnits(app *procfile.Application) error { + err := os.MkdirAll(e.Config.HelperDir, 0755) + + if err != nil { + return err + } + + for _, service := range app.Services { + fullServiceName := app.Name + "-" + service.Name + + service.HelperPath = e.helperPath(fullServiceName) + + helperData, err := e.Provider.RenderHelperTemplate(service) + + if err != nil { + return err + } + + unitData, err := e.Provider.RenderServiceTemplate(service) + + if err != nil { + return err + } + + unitPath := e.unitPath(fullServiceName) + + err = ioutil.WriteFile(unitPath, []byte(unitData), 0644) + + if err != nil { + return err + } + + log.Debug("Service unit saved as %s", unitPath) + + err = ioutil.WriteFile(service.HelperPath, []byte(helperData), 0755) + + if err != nil { + return err + } + + log.Debug("Helper saved as %s", service.HelperPath) + } + + return nil +} + +// unitPath return path for unit +func (e *Exporter) unitPath(name string) string { + return path.Join(e.Config.TargetDir, e.Provider.UnitName(name)) +} + +// helperPath return path for helper +func (e *Exporter) helperPath(name string) string { + return path.Join(e.Config.HelperDir, name+".sh") +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// deleteByMask delete all files witch +func deleteByMask(dir, mask string) error { + files := fsutil.List( + dir, true, + &fsutil.ListingFilter{ + MatchPatterns: []string{mask}, + }, + ) + + fsutil.ListToAbsolute(dir, files) + + if len(files) == 0 { + return nil + } + + for _, file := range files { + log.Debug("File %s removed", file) + + err := os.Remove(file) + + if err != nil { + return err + } + } + + return nil +} diff --git a/export/provider.go b/export/provider.go new file mode 100644 index 0000000..0af3d67 --- /dev/null +++ b/export/provider.go @@ -0,0 +1,66 @@ +package export + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "bytes" + "fmt" + "text/template" + + "pkg.re/essentialkaos/ek.v6/log" + + "github.com/funbox/init-exporter/procfile" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +type Provider interface { + // UnitName return unit name with extension + UnitName(name string) string + + // RenderAppTemplate render unit template data with given app data and return + // app unit code + RenderAppTemplate(app *procfile.Application) (string, error) + + // RenderServiceTemplate render unit template data with given service data and + // return service unit code + RenderServiceTemplate(service *procfile.Service) (string, error) + + // RenderHelperTemplate render helper template data with given service data and + // return helper script code + RenderHelperTemplate(service *procfile.Service) (string, error) + + // EnableService enable service with given name + EnableService(appName string) error + + // DisableService disable service with given name + DisableService(appName string) error +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// renderTemplate renders template data +func renderTemplate(name, templateData string, data interface{}) (string, error) { + templ, err := template.New(name).Parse(templateData) + + if err != nil { + log.Error(err.Error()) + return "", fmt.Errorf("Can't render template") + } + + var buffer bytes.Buffer + + ct := template.Must(templ, nil) + err = ct.Execute(&buffer, data) + + if err != nil { + log.Error(err.Error()) + return "", fmt.Errorf("Can't render template") + } + + return buffer.String(), nil +} diff --git a/export/systemd.go b/export/systemd.go new file mode 100644 index 0000000..488b760 --- /dev/null +++ b/export/systemd.go @@ -0,0 +1,200 @@ +package export + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "strings" + "time" + + "pkg.re/essentialkaos/ek.v6/system" + "pkg.re/essentialkaos/ek.v6/timeutil" + + "github.com/funbox/init-exporter/procfile" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +// SystemdProvider is systemd export provider +type SystemdProvider struct{} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// TEMPLATE_SYSTEMD_HELPER contains default helper template +const TEMPLATE_SYSTEMD_HELPER = `#!/bin/bash + +# This helper generated {{.ExportDate}} by init-exporter/systemd for {{.Application.Name}} application + +[[ -r /etc/profile.d/rbenv.sh ]] && source /etc/profile.d/rbenv.sh + +exec {{.Service.Cmd}} +` + +// TEMPLATE_SYSTEMD_APP contains default application template +const TEMPLATE_SYSTEMD_APP = `# This unit generated {{.ExportDate}} by init-exporter/systemd for {{.Application.Name}} application + +[Unit] + +Description=Unit for {{.Application.Name}} application +After={{.StartLevel}} +Wants={{.Wants}} + +[Service] +Type=oneshot +RemainAfterExit=true + +ExecStartPre=/bin/mkdir -p /var/log/{{.Application.Name}} +ExecStartPre=/bin/chown -R {{.Application.User}} /var/log/{{.Application.Name}} +ExecStartPre=/bin/chgrp -R {{.Application.Group}} /var/log/{{.Application.Name}} +ExecStartPre=/bin/chmod -R g+w /var/log/{{.Application.Name}} +ExecStart=/bin/echo "{{.Application.Name}} started" +ExecStop=/bin/echo "{{.Application.Name}} stopped" + +[Install] +WantedBy={{.StartLevel}} +` + +// TEMPLATE_SYSTEMD_SERVICE contains default service template +const TEMPLATE_SYSTEMD_SERVICE = `# This unit generated {{.ExportDate}} by init-exporter/systemd for {{.Application.Name}} application + +[Unit] + +Description=Unit for {{.Service.Name}} service (part of {{.Application.Name}} application) +PartOf={{.Application.Name}}.service + +[Service] +Type=simple + +TimeoutStopSec={{.Service.Options.KillTimeout}} +{{ if .Service.Options.IsRespawnEnabled }}Restart=on-failure{{ end }} +{{ if .Service.Options.IsRespawnLimitSet }}StartLimitInterval={{.Service.Options.RespawnInterval}}{{ end }} +{{ if .Service.Options.IsRespawnLimitSet }}StartLimitBurst={{.Service.Options.RespawnCount}}{{ end }} + +{{ if .Service.Options.IsFileLimitSet }}LimitNOFILE={{.Service.Options.LimitFile}}{{ end }} +{{ if .Service.Options.IsProcLimitSet }}LimitNPROC={{.Service.Options.LimitProc}}{{ end }} + +ExecStartPre=/bin/touch /var/log/{{.Application.Name}}/{{.Service.Name}}.log +ExecStartPre=/bin/chown {{.Application.User}} /var/log/{{.Application.Name}}/{{.Service.Name}}.log +ExecStartPre=/bin/chgrp {{.Application.Group}} /var/log/{{.Application.Name}}/{{.Service.Name}}.log +ExecStartPre=/bin/chmod g+w /var/log/{{.Application.Name}}/{{.Service.Name}}.log + +User={{.Application.User}} +Group={{.Application.Group}} +WorkingDirectory={{.Service.Options.WorkingDir}} +{{ if .Service.Options.IsEnvSet }}Environment={{.Service.Options.EnvString}}{{ end }} +ExecStart=/bin/bash {{.Service.HelperPath}} {{ if .Service.Options.IsCustomLogEnabled }}>> {{.Service.Options.LogPath}} {{end}}>> /var/log/{{.Application.Name}}/{{.Service.Name}}.log 2>&1 +` + +// ////////////////////////////////////////////////////////////////////////////////// // + +type systemdAppData struct { + Application *procfile.Application + ExportDate string + StartLevel string + StopLevel string + Wants string +} + +type systemdServiceData struct { + Application *procfile.Application + Service *procfile.Service + ExportDate string + StartLevel string + StopLevel string +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// NewSystemd create new SystemdProvider struct +func NewSystemd() *SystemdProvider { + return &SystemdProvider{} +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// UnitName return unit name with extension +func (sp *SystemdProvider) UnitName(name string) string { + return name + ".service" +} + +// EnableService enable service with given name +func (sp *SystemdProvider) EnableService(appName string) error { + return system.Exec("systemctl", "enable", sp.UnitName(appName)) +} + +// DisableService disable service with given name +func (sp *SystemdProvider) DisableService(appName string) error { + return system.Exec("systemctl", "disable", sp.UnitName(appName)) +} + +// RenderAppTemplate render unit template data with given app data and return +// app unit code +func (sp *SystemdProvider) RenderAppTemplate(app *procfile.Application) (string, error) { + data := &systemdAppData{ + Application: app, + Wants: sp.renderWantsClause(app), + StartLevel: sp.randerLevel(app.StartLevel), + StopLevel: sp.randerLevel(app.StopLevel), + ExportDate: timeutil.Format(time.Now(), "%Y/%m/%d %H:%M:%S"), + } + + return renderTemplate("systemd-app-template", TEMPLATE_SYSTEMD_APP, data) +} + +// RenderServiceTemplate render unit template data with given service data and +// return service unit code +func (sp *SystemdProvider) RenderServiceTemplate(service *procfile.Service) (string, error) { + data := systemdServiceData{ + Application: service.Application, + Service: service, + StartLevel: sp.randerLevel(service.Application.StartLevel), + StopLevel: sp.randerLevel(service.Application.StopLevel), + ExportDate: timeutil.Format(time.Now(), "%Y/%m/%d %H:%M:%S"), + } + + return renderTemplate("systemd-service-template", TEMPLATE_SYSTEMD_SERVICE, data) +} + +// RenderHelperTemplate render helper template data with given service data and +// return helper script code +func (sp *SystemdProvider) RenderHelperTemplate(service *procfile.Service) (string, error) { + data := systemdServiceData{ + Application: service.Application, + Service: service, + StartLevel: sp.randerLevel(service.Application.StartLevel), + StopLevel: sp.randerLevel(service.Application.StopLevel), + ExportDate: timeutil.Format(time.Now(), "%Y/%m/%d %H:%M:%S"), + } + + return renderTemplate("systemd-helper-template", TEMPLATE_SYSTEMD_HELPER, data) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// randerLevel convert level number to upstart level name +func (sp *SystemdProvider) randerLevel(level int) string { + switch level { + case 1: + return "rescue.target" + case 5: + return "graphical.target" + case 6: + return "reboot.target" + default: + return "multi-user.target" + } +} + +// renderWantsClause render list of services in application for upstart config +func (sp *SystemdProvider) renderWantsClause(app *procfile.Application) string { + var wants []string + + for _, service := range app.Services { + wants = append(wants, sp.UnitName(app.Name+"-"+service.Name)) + } + + return strings.Join(wants, " ") +} diff --git a/export/upstart.go b/export/upstart.go new file mode 100644 index 0000000..81167cb --- /dev/null +++ b/export/upstart.go @@ -0,0 +1,156 @@ +package export + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "fmt" + "time" + + "pkg.re/essentialkaos/ek.v6/timeutil" + + "github.com/funbox/init-exporter/procfile" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +// UpstartProvider is upstart export provider +type UpstartProvider struct{} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// TEMPLATE_UPSTART_HELPER contains default helper template +const TEMPLATE_UPSTART_HELPER = `#!/bin/bash + +# This helper generated {{.ExportDate}} by init-exporter/upstart for {{.Application.Name}} application + +[[ -r /etc/profile.d/rbenv.sh ]] && source /etc/profile.d/rbenv.sh + +cd {{.Service.Options.WorkingDir}} && exec {{ if .Service.Options.IsEnvSet }}{{.Service.Options.EnvString}} {{ end }}{{.Service.Cmd}} +` + +// TEMPLATE_UPSTART_APP contains default application template +const TEMPLATE_UPSTART_APP = `# This unit generated {{.ExportDate}} by init-exporter/upstart for {{.Application.Name}} application + +start on {{.StartLevel}} +stop on {{.StopLevel}} + +pre-start script + +bash << "EOF" + mkdir -p /var/log/{{.Application.Name}} + chown -R {{.Application.User}} /var/log/{{.Application.Name}} + chgrp -R {{.Application.Group}} /var/log/{{.Application.Name}} + chmod -R g+w /var/log/{{.Application.Name}} +EOF + +end script +` + +// TEMPLATE_UPSTART_SERVICE contains default service template +const TEMPLATE_UPSTART_SERVICE = `# This unit generated {{.ExportDate}} by init-exporter/upstart for {{.Application.Name}} application + +start on {{.StartLevel}} +stop on {{.StopLevel}} + +{{ if .Service.Options.IsRespawnEnabled }}respawn{{ end }} +{{ if .Service.Options.IsRespawnLimitSet }}respawn limit {{.Service.Options.RespawnCount}} {{.Service.Options.RespawnInterval}}{{ end }} + +kill timeout {{.Service.Options.KillTimeout}} + +{{ if .Service.Options.IsFileLimitSet }}limit nofile {{.Service.Options.LimitFile}} {{.Service.Options.LimitFile}}{{ end }} +{{ if .Service.Options.IsProcLimitSet }}limit nproc {{.Service.Options.LimitProc}} {{.Service.Options.LimitProc}}{{ end }} + +script + touch /var/log/{{.Application.Name}}/{{.Service.Name}}.log + chown {{.Application.User}} /var/log/{{.Application.Name}}/{{.Service.Name}}.log + chgrp {{.Application.Group}} /var/log/{{.Application.Name}}/{{.Service.Name}}.log + chmod g+w /var/log/{{.Application.Name}}/{{.Service.Name}}.log + exec sudo -u {{.Application.User}} /bin/bash {{.Service.HelperPath}} {{ if .Service.Options.IsCustomLogEnabled }}>> {{.Service.Options.LogPath}} {{end}}>> /var/log/{{.Application.Name}}/{{.Service.Name}}.log 2>&1 +end script +` + +// ////////////////////////////////////////////////////////////////////////////////// // + +type upstartAppData struct { + Application *procfile.Application + ExportDate string + StartLevel string + StopLevel string +} + +type upstartServiceData struct { + Application *procfile.Application + Service *procfile.Service + ExportDate string + StartLevel string + StopLevel string +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// NewUpstart create new UpstartProvider struct +func NewUpstart() *UpstartProvider { + return &UpstartProvider{} +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// UnitName return unit name with extension +func (up *UpstartProvider) UnitName(name string) string { + return name + ".conf" +} + +// EnableService enable service with given name +func (up *UpstartProvider) EnableService(appName string) error { + return nil +} + +// DisableService disable service with given name +func (up *UpstartProvider) DisableService(appName string) error { + return nil +} + +// RenderAppTemplate render unit template data with given app data and return +// app unit code +func (up *UpstartProvider) RenderAppTemplate(app *procfile.Application) (string, error) { + data := &upstartAppData{ + Application: app, + StartLevel: fmt.Sprintf("[%d]", app.StartLevel), + StopLevel: fmt.Sprintf("[%d]", app.StopLevel), + ExportDate: timeutil.Format(time.Now(), "%Y/%m/%d %H:%M:%S"), + } + + return renderTemplate("upstart-app-template", TEMPLATE_UPSTART_APP, data) +} + +// RenderServiceTemplate render unit template data with given service data and +// return service unit code +func (up *UpstartProvider) RenderServiceTemplate(service *procfile.Service) (string, error) { + data := &upstartServiceData{ + Application: service.Application, + Service: service, + StartLevel: fmt.Sprintf("[%d]", service.Application.StartLevel), + StopLevel: fmt.Sprintf("[%d]", service.Application.StopLevel), + ExportDate: timeutil.Format(time.Now(), "%Y/%m/%d %H:%M:%S"), + } + + return renderTemplate("upstart-service-template", TEMPLATE_UPSTART_SERVICE, data) +} + +// RenderHelperTemplate render helper template data with given service data and +// return helper script code +func (up *UpstartProvider) RenderHelperTemplate(service *procfile.Service) (string, error) { + data := &upstartServiceData{ + Application: service.Application, + Service: service, + StartLevel: fmt.Sprintf("[%d]", service.Application.StartLevel), + StopLevel: fmt.Sprintf("[%d]", service.Application.StopLevel), + ExportDate: timeutil.Format(time.Now(), "%Y/%m/%d %H:%M:%S"), + } + + return renderTemplate("upstart-helper-template", TEMPLATE_UPSTART_HELPER, data) +} diff --git a/glide.lock b/glide.lock new file mode 100644 index 0000000..5a1ccaa --- /dev/null +++ b/glide.lock @@ -0,0 +1,25 @@ +hash: 352daf3640af334921e1e295d0738306592c7c4609878e4653936e2e6c7cc749 +updated: 2017-02-02T04:29:34.527741033-05:00 +imports: +- name: pkg.re/essentialkaos/ek.v6 + version: c292a0da0a81e93c1161a291370c87369db8fc2c + subpackages: + - arg + - env + - errutil + - fmtc + - fsutil + - knf + - log + - path + - pluralize + - system + - timeutil + - usage +- name: pkg.re/essentialkaos/go-simpleyaml.v1 + version: 6c4951f5cc065c5724d0b2f46a5036cc1f1f57a1 +- name: pkg.re/yaml.v2 + version: 4c78c975fe7c825c6d1466c42be594d1d6f3aba6 +testImports: +- name: pkg.re/check.v1 + version: 20d25e2804050c1cd24a7eea1e7a6447dd0e74ec diff --git a/glide.yaml b/glide.yaml new file mode 100644 index 0000000..67c3ce3 --- /dev/null +++ b/glide.yaml @@ -0,0 +1,18 @@ +package: github.com/funbox/init-exporter +import: +- package: pkg.re/essentialkaos/ek.v6 + subpackages: + - arg + - env + - errutil + - fmtc + - fsutil + - knf + - log + - path + - system + - timeutil + - usage +- package: pkg.re/essentialkaos/go-simpleyaml.v1 +testImport: +- package: pkg.re/check.v1 diff --git a/init-exporter.go b/init-exporter.go new file mode 100644 index 0000000..1650f3a --- /dev/null +++ b/init-exporter.go @@ -0,0 +1,17 @@ +package main + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + CLI "github.com/funbox/init-exporter/cli" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +func main() { + CLI.Init() +} diff --git a/procfile/procfile.go b/procfile/procfile.go new file mode 100644 index 0000000..4cd8ed4 --- /dev/null +++ b/procfile/procfile.go @@ -0,0 +1,664 @@ +package procfile + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "regexp" + "sort" + "strings" + + "pkg.re/essentialkaos/ek.v6/errutil" + "pkg.re/essentialkaos/ek.v6/fsutil" + "pkg.re/essentialkaos/ek.v6/log" + "pkg.re/essentialkaos/ek.v6/path" + + "pkg.re/essentialkaos/go-simpleyaml.v1" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +const ( + REGEXP_V1_LINE = `^([A-z\d_]+):\s*(.+)` + REGEXP_V2_VERSION = `(?m)^\s*version:\s*2\s*$` + REGEXP_PATH_CHECK = `\A[A-Za-z0-9_\-./]+\z` + REGEXP_VALUE_CHECK = `\A[A-Za-z0-9_\-]+\z` +) + +const ( + DEFAULT_RESPAWN_INTERVAL = 5 + DEFAULT_RESPAWN_COUNT = 10 +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +type Config struct { + Name string // Application name + User string // Working user + Group string // Working group + WorkingDir string // Working directory + LimitProc int // Global processes limit + LimitFile int // Global descriptors limit +} + +type Service struct { + Name string // Service name + Cmd string // Command + Options *ServiceOptions // Service options + Application *Application // Pointer to parent application + HelperPath string // Path to helper (will be set by exporter) +} + +type ServiceOptions struct { + Env map[string]string // Environment variables + WorkingDir string // Working directory + LogPath string // Path to log file + KillTimeout int // Kill timeout in seconds + Count int // Exec count + RespawnInterval int // Respawn interval in seconds + RespawnCount int // Respawn count + IsRespawnEnabled bool // Respawn enabled flag + LimitProc int // Processes limit + LimitFile int // Descriptors limit +} + +type Application struct { + Name string // Name of application + Services []*Service // List of services in application + User string // Working user + Group string // Working group + StartLevel int // Start level + StopLevel int // Stop level + WorkingDir string // Working directory + ProcVersion int // Proc version 1/2 +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// Read reads and parse procfile content +func Read(path string, config *Config) (*Application, error) { + log.Debug("Processing file %s", path) + + if !fsutil.IsExist(path) { + return nil, fmt.Errorf("Procfile %s is not exist", path) + } + + if !fsutil.IsRegular(path) { + return nil, fmt.Errorf("%s is not a file", path) + } + + if !fsutil.IsNonEmpty(path) { + return nil, fmt.Errorf("Procfile %s is empty", path) + } + + if !fsutil.IsReadable(path) { + return nil, fmt.Errorf("Procfile %s is not readable", path) + } + + data, err := ioutil.ReadFile(path) + + if err != nil { + return nil, err + } + + switch determineProcVersion(data) { + + case 1: + return parseV1Procfile(data, config) + + case 2: + return parseV2Procfile(data, config) + + } + + return nil, fmt.Errorf("Can't determine version for procfile %s", path) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// Validate validate all services in application +func (a *Application) Validate() error { + errs := errutil.NewErrors() + + errs.Add(checkRunLevel(a.StartLevel)) + errs.Add(checkRunLevel(a.StopLevel)) + + for _, service := range a.Services { + errs.Add(service.Validate()) + } + + return errs.Last() +} + +// Validate validate service props and options +func (s *Service) Validate() error { + errs := errutil.NewErrors() + + errs.Add(checkValue(s.Name)) + errs.Add(s.Options.Validate()) + + return errs.Last() +} + +// Validate validate service options +func (so *ServiceOptions) Validate() error { + errs := errutil.NewErrors() + + errs.Add(checkPath(so.WorkingDir)) + errs.Add(checkPath(so.LogPath)) + + for envName, envVal := range so.Env { + errs.Add(checkEnv(envName, envVal)) + } + + return errs.Last() +} + +// IsRespawnLimitSet return true if respawn options is set +func (so *ServiceOptions) IsRespawnLimitSet() bool { + return so.RespawnCount != 0 || so.RespawnInterval != 0 +} + +// IsCustomLogEnabled return true if service have custom log +func (so *ServiceOptions) IsCustomLogEnabled() bool { + return so.LogPath != "" +} + +// IsEnvSet return true if service have custom env vars +func (so *ServiceOptions) IsEnvSet() bool { + return len(so.Env) != 0 +} + +// IsFileLimitSet return true if descriptors limit is set +func (so *ServiceOptions) IsFileLimitSet() bool { + return so.LimitFile != 0 +} + +// IsProcLimitSet return true if processes limit is set +func (so *ServiceOptions) IsProcLimitSet() bool { + return so.LimitProc != 0 +} + +// EnvString return environment variables as string +func (so *ServiceOptions) EnvString() string { + if len(so.Env) == 0 { + return "" + } + + var clauses []string + + for k, v := range so.Env { + clauses = append(clauses, k+"="+v) + } + + sort.Strings(clauses) + + return strings.Join(clauses, " ") +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// parseV1Procfile parse v1 procfile data +func parseV1Procfile(data []byte, config *Config) (*Application, error) { + if config == nil { + return nil, fmt.Errorf("Config is nil") + } + + log.Debug("Parsing procfile as v1") + + var services []*Service + + reader := bytes.NewReader(data) + scanner := bufio.NewScanner(reader) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + switch { + case line == "": + // Skip empty line + case strings.HasPrefix(line, "#"): + // Skip comment + default: + service, err := parseV1Line(line) + + if err != nil { + return nil, err + } + + if service.Options.LimitFile == 0 && config.LimitFile != 0 { + service.Options.LimitFile = config.LimitFile + } + + if service.Options.LimitProc == 0 && config.LimitProc != 0 { + service.Options.LimitProc = config.LimitProc + } + + services = append(services, service) + } + } + + err := scanner.Err() + + if err != nil { + return nil, err + } + + app := &Application{ + ProcVersion: 1, + Name: config.Name, + User: config.User, + StartLevel: 3, + StopLevel: 3, + Group: config.Group, + WorkingDir: config.WorkingDir, + Services: services, + } + + addCrossLink(app) + + return app, nil +} + +// parseV1Line parse v1 procfile line +func parseV1Line(line string) (*Service, error) { + re := regexp.MustCompile(REGEXP_V1_LINE) + matches := re.FindStringSubmatch(line) + + if len(matches) != 3 { + return nil, fmt.Errorf("Procfile v1 should have format: 'some_label: command'") + } + + cmd, options := parseV1Command(matches[2]) + + return &Service{Name: matches[1], Cmd: cmd, Options: options}, nil +} + +// parseV1Command parse command and extract command and working dir +func parseV1Command(cmd string) (string, *ServiceOptions) { + var options = &ServiceOptions{} + + if !strings.HasPrefix(cmd, "cd ") && !strings.Contains(cmd, "&&") { + return cmd, options + } + + cmdSlice := strings.Split(cmd, "&&") + command := strings.TrimSpace(cmdSlice[1]) + workingDir := strings.Replace(cmdSlice[0], "cd", "", -1) + + options.WorkingDir = strings.TrimSpace(workingDir) + + if strings.HasPrefix(command, "env ") { + evMap := make(map[string]string) + + subCommandSlice := strings.Fields(command) + + for i, commandPart := range subCommandSlice { + if commandPart == "env" { + continue + } + + if !strings.Contains(commandPart, "=") { + command = strings.Join(subCommandSlice[i:], " ") + break + } + + envSlice := strings.Split(commandPart, "=") + evMap[envSlice[0]] = envSlice[1] + } + + options.Env = evMap + } + + return command, options +} + +// parseV2Procfile parse v2 procfile data +func parseV2Procfile(data []byte, config *Config) (*Application, error) { + var err error + + log.Debug("Parsing procfile as v2") + + yaml, err := simpleyaml.NewYaml(data) + + if err != nil { + return nil, err + } + + commands, err := yaml.Get("commands").Map() + + if err != nil { + return nil, fmt.Errorf("Commands missing in Procfile") + } + + services, err := parseCommands(yaml, commands, config) + + if err != nil { + return nil, err + } + + app := &Application{ + ProcVersion: 2, + Name: config.Name, + User: config.User, + Group: config.Group, + StartLevel: 3, + StopLevel: 3, + WorkingDir: config.WorkingDir, + Services: services, + } + + if yaml.IsExist("working_directory") { + app.WorkingDir, err = yaml.Get("working_directory").String() + + if err != nil { + return nil, fmt.Errorf("Can't parse working_directory value: %v", err) + } + } + + if yaml.IsExist("start_on_runlevel") { + app.StartLevel, err = yaml.Get("start_on_runlevel").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse start_on_runlevel value: %v", err) + } + } + + if yaml.IsExist("stop_on_runlevel") { + app.StopLevel, err = yaml.Get("stop_on_runlevel").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse stop_on_runlevel value: %v", err) + } + } + + addCrossLink(app) + + return app, nil +} + +// parseCommands parse command section in yaml based procfile +func parseCommands(yaml *simpleyaml.Yaml, commands map[interface{}]interface{}, config *Config) ([]*Service, error) { + var services []*Service + + commonOptions, err := parseOptions(yaml) + + if err != nil { + return nil, err + } + + for key := range commands { + serviceName := fmt.Sprint(key) + commandYaml := yaml.GetPath("commands", serviceName) + serviceCmd, err := commandYaml.Get("command").String() + + if err != nil { + return nil, err + } + + serviceOptions, err := parseOptions(commandYaml) + + if err != nil { + return nil, err + } + + mergeServiceOptions(serviceOptions, commonOptions) + + if serviceOptions.LimitFile == 0 && config.LimitFile != 0 { + serviceOptions.LimitFile = config.LimitFile + } + + if serviceOptions.LimitProc == 0 && config.LimitProc != 0 { + serviceOptions.LimitProc = config.LimitProc + } + + service := &Service{ + Name: serviceName, + Cmd: serviceCmd, + Options: serviceOptions, + } + + services = append(services, service) + } + + return services, nil +} + +// parseOptions parse service options in yaml based procfile +func parseOptions(yaml *simpleyaml.Yaml) (*ServiceOptions, error) { + var err error + + options := &ServiceOptions{ + Env: make(map[string]string), + IsRespawnEnabled: true, + } + + if yaml.IsExist("working_directory") { + options.WorkingDir, err = yaml.Get("working_directory").String() + + if err != nil { + return nil, fmt.Errorf("Can't parse working_directory value: %v", err) + } + } + + if yaml.IsExist("log") { + options.LogPath, err = yaml.Get("log").String() + + if err != nil { + return nil, fmt.Errorf("Can't parse log value: %v", err) + } + } + + if yaml.IsExist("kill_timeout") { + options.KillTimeout, err = yaml.Get("kill_timeout").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse kill_timeout value: %v", err) + } + } + + if yaml.IsExist("count") { + options.Count, err = yaml.Get("count").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse count value: %v", err) + } + } + + if yaml.IsExist("env") { + env, err := yaml.Get("env").Map() + + if err != nil { + return nil, fmt.Errorf("Can't parse env value: %v", err) + } + + options.Env = convertMapType(env) + } + + if yaml.IsPathExist("respawn", "count") || yaml.IsPathExist("respawn", "interval") { + if yaml.IsPathExist("respawn", "count") { + options.RespawnCount, err = yaml.Get("respawn").Get("count").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse respawn.count value: %v", err) + } + } else { + options.RespawnCount = DEFAULT_RESPAWN_COUNT + } + + if yaml.IsPathExist("respawn", "interval") { + options.RespawnInterval, err = yaml.Get("respawn").Get("interval").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse respawn.interval value: %v", err) + } + } else { + options.RespawnInterval = DEFAULT_RESPAWN_INTERVAL + } + + } else if yaml.IsExist("respawn") { + options.IsRespawnEnabled, err = yaml.Get("respawn").Bool() + + if err != nil { + return nil, fmt.Errorf("Can't parse respawn value: %v", err) + } + } + + if yaml.IsPathExist("limits", "nproc") || yaml.IsPathExist("limits", "nofile") { + if yaml.IsPathExist("limits", "nofile") { + options.LimitFile, err = yaml.Get("limits").Get("nofile").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse limits.nofile value: %v", err) + } + } + + if yaml.IsPathExist("limits", "nproc") { + options.LimitProc, err = yaml.Get("limits").Get("nproc").Int() + + if err != nil { + return nil, fmt.Errorf("Can't parse limits.nproc value: %v", err) + } + } + } + + return options, nil +} + +// determineProcVersion process procfile data and return procfile version +func determineProcVersion(data []byte) int { + if regexp.MustCompile(REGEXP_V2_VERSION).Match(data) { + return 2 + } + + return 1 +} + +// convertMapType convert map with interface{} to map with string +func convertMapType(m map[interface{}]interface{}) map[string]string { + result := make(map[string]string) + + for k, v := range m { + result[k.(string)] = fmt.Sprint(v) + } + + return result +} + +// mergeServiceOptions merge two ServiceOptions structs +func mergeServiceOptions(dst, src *ServiceOptions) { + + mergeStringMaps(dst.Env, src.Env) + + if dst.WorkingDir == "" { + dst.WorkingDir = src.WorkingDir + } + + if dst.LogPath == "" { + dst.LogPath = src.LogPath + } + + if dst.KillTimeout == 0 { + dst.KillTimeout = src.KillTimeout + } + + if dst.RespawnInterval == 0 { + dst.RespawnInterval = src.RespawnInterval + } + + if dst.RespawnCount == 0 { + dst.RespawnCount = src.RespawnCount + } + + if dst.LimitFile == 0 { + dst.LimitFile = src.LimitFile + } + + if dst.LimitProc == 0 { + dst.LimitProc = src.LimitProc + } +} + +// mergeStringMaps merges two maps +func mergeStringMaps(dest, src map[string]string) { + for k, v := range src { + if dest[k] == "" { + dest[k] = v + } + } +} + +// checkPath check path value and return error if value is insecure +func checkPath(value string) error { + if value == "" { + return nil + } + + if !regexp.MustCompile(REGEXP_PATH_CHECK).MatchString(value) { + return fmt.Errorf("Path %s is insecure and can't be accepted", value) + } + + if !path.IsSafe(value) { + return fmt.Errorf("Path %s is not safe and can't be accepted", value) + } + + return nil +} + +// checkValue check any value and return error if value is insecure +func checkValue(value string) error { + if value == "" { + return nil + } + + if !regexp.MustCompile(REGEXP_VALUE_CHECK).MatchString(value) { + return fmt.Errorf("Value %s is insecure and can't be accepted", value) + } + + return nil +} + +// checkEnv check given env variable and return error if name or value is insecure +func checkEnv(name, value string) error { + if name == "" || value == "" { + return nil + } + + if !regexp.MustCompile(REGEXP_VALUE_CHECK).MatchString(name) { + return fmt.Errorf("Environment variable name %s is insecure and can't be accepted", value) + } + + if !regexp.MustCompile(REGEXP_VALUE_CHECK).MatchString(value) { + return fmt.Errorf("Environment variable value %s is insecure and can't be accepted", value) + } + + return nil +} + +// checkRunLevel check run level value and return error if value is insecure +func checkRunLevel(value int) error { + if value < 1 { + return fmt.Errorf("Run level can't be less than 1") + } + + if value > 6 { + return fmt.Errorf("Run level can't be greater than 6") + } + + return nil +} + +// addCrossLink add to all service structs pointer +// to parent application struct +func addCrossLink(app *Application) { + for _, service := range app.Services { + service.Application = app + } +} diff --git a/procfile/procfile_test.go b/procfile/procfile_test.go new file mode 100644 index 0000000..f9c51dc --- /dev/null +++ b/procfile/procfile_test.go @@ -0,0 +1,144 @@ +package procfile + +// ////////////////////////////////////////////////////////////////////////////////// // +// // +// Copyright (c) 2006-2017 FB GROUP LLC // +// // +// ////////////////////////////////////////////////////////////////////////////////// // + +import ( + "testing" + + . "pkg.re/check.v1" +) + +// ////////////////////////////////////////////////////////////////////////////////// // + +func Test(t *testing.T) { TestingT(t) } + +type ProcfileSuite struct{} + +// ////////////////////////////////////////////////////////////////////////////////// // + +var _ = Suite(&ProcfileSuite{}) + +// ////////////////////////////////////////////////////////////////////////////////// // + +func (s *ProcfileSuite) TestProcV1Parsing(c *C) { + app, err := Read("../testdata/procfile_v1", &Config{Name: "test-app"}) + + c.Assert(err, IsNil) + c.Assert(app, NotNil) + + c.Assert(app.ProcVersion, Equals, 1) + c.Assert(app.Services, HasLen, 3) + + c.Assert(app.Services[0].Name, Equals, "my_tail_cmd") + c.Assert(app.Services[0].Cmd, Equals, "/usr/bin/tail -F /var/log/messages") + + c.Assert(app.Services[1].Name, Equals, "my_another_tail_cmd") + c.Assert(app.Services[1].Cmd, Equals, "/usr/bin/tailf /var/log/messages") + + c.Assert(app.Services[2].Name, Equals, "cmd_with_cd") + c.Assert(app.Services[2].Cmd, Equals, "/usr/bin/tail -F /var/log/messages") + c.Assert(app.Services[2].Options, NotNil) + c.Assert(app.Services[2].Options.Env, HasLen, 2) + c.Assert(app.Services[2].Options.Env["ENV_TEST"], Equals, "100") + c.Assert(app.Services[2].Options.Env["SOME_ENV"], Equals, "test") + c.Assert(app.Services[2].Options.WorkingDir, Equals, "/srv/service") + + c.Assert(app.Validate(), IsNil) +} + +func (s *ProcfileSuite) TestProcV2Parsing(c *C) { + app, err := Read("../testdata/procfile_v2", &Config{Name: "test-app"}) + + c.Assert(err, IsNil) + c.Assert(app, NotNil) + + c.Assert(app.ProcVersion, Equals, 2) + c.Assert(app.Services, HasLen, 4) + + c.Assert(app.StartLevel, Equals, 2) + c.Assert(app.StopLevel, Equals, 5) + + for _, service := range app.Services { + switch service.Name { + case "my_tail_cmd": + c.Assert(service.Cmd, Equals, "/usr/bin/tail -F /var/log/messages") + c.Assert(service.Options, NotNil) + c.Assert(service.Options.WorkingDir, Equals, "/var/...") + c.Assert(service.Options.IsCustomLogEnabled(), Equals, false) + c.Assert(service.Options.RespawnCount, Equals, 5) + c.Assert(service.Options.RespawnInterval, Equals, 10) + c.Assert(service.Options.IsRespawnEnabled, Equals, true) + c.Assert(service.Options.Env, NotNil) + c.Assert(service.Options.Env["RAILS_ENV"], Equals, "staging") + c.Assert(service.Options.Env["TEST"], Equals, "true") + c.Assert(service.Options.EnvString(), Equals, "RAILS_ENV=staging TEST=true") + c.Assert(service.Options.LimitFile, Equals, 4096) + c.Assert(service.Options.LimitProc, Equals, 4096) + c.Assert(service.Application, NotNil) + c.Assert(service.Application.Name, Equals, "test-app") + + case "my_another_tail_cmd": + c.Assert(service.Cmd, Equals, "/usr/bin/tail -F /var/log/messages") + c.Assert(service.Options, NotNil) + c.Assert(service.Options.WorkingDir, Equals, "/srv/projects/my_website/current") + c.Assert(service.Options.IsCustomLogEnabled(), Equals, false) + c.Assert(service.Options.KillTimeout, Equals, 60) + c.Assert(service.Options.RespawnCount, Equals, 7) + c.Assert(service.Options.RespawnInterval, Equals, 22) + c.Assert(service.Options.IsRespawnEnabled, Equals, false) + c.Assert(service.Options.Env, NotNil) + c.Assert(service.Options.Env["RAILS_ENV"], Equals, "production") + c.Assert(service.Options.Env["TEST"], Equals, "true") + c.Assert(service.Options.EnvString(), Equals, "RAILS_ENV=production TEST=true") + c.Assert(service.Options.LimitFile, Equals, 8192) + c.Assert(service.Options.LimitProc, Equals, 8192) + c.Assert(service.Application, NotNil) + c.Assert(service.Application.Name, Equals, "test-app") + + case "my_one_another_tail_cmd": + c.Assert(service.Cmd, Equals, "/usr/bin/tail -F /var/log/messages") + c.Assert(service.Options, NotNil) + c.Assert(service.Options.WorkingDir, Equals, "/srv/projects/my_website/current") + c.Assert(service.Options.LogPath, Equals, "/var/log/messages_copy") + c.Assert(service.Options.IsCustomLogEnabled(), Equals, true) + c.Assert(service.Options.RespawnCount, Equals, 7) + c.Assert(service.Options.RespawnInterval, Equals, 22) + c.Assert(service.Options.IsRespawnEnabled, Equals, true) + c.Assert(service.Options.Env, NotNil) + c.Assert(service.Options.Env["RAILS_ENV"], Equals, "production") + c.Assert(service.Options.Env["TEST"], Equals, "true") + c.Assert(service.Options.EnvString(), Equals, "RAILS_ENV=production TEST=true") + c.Assert(service.Options.LimitFile, Equals, 4096) + c.Assert(service.Options.LimitProc, Equals, 4096) + c.Assert(service.Application, NotNil) + c.Assert(service.Application.Name, Equals, "test-app") + + case "my_multi_tail_cmd": + c.Assert(service.Cmd, Equals, "/usr/bin/tail -F /var/log/messages") + c.Assert(service.Options, NotNil) + c.Assert(service.Options.Count, Equals, 2) + c.Assert(service.Options.WorkingDir, Equals, "/srv/projects/my_website/current") + c.Assert(service.Options.IsCustomLogEnabled(), Equals, false) + c.Assert(service.Options.RespawnCount, Equals, 7) + c.Assert(service.Options.RespawnInterval, Equals, 22) + c.Assert(service.Options.IsRespawnEnabled, Equals, true) + c.Assert(service.Options.Env, NotNil) + c.Assert(service.Options.Env["RAILS_ENV"], Equals, "production") + c.Assert(service.Options.Env["TEST"], Equals, "true") + c.Assert(service.Options.EnvString(), Equals, "RAILS_ENV=production TEST=true") + c.Assert(service.Options.LimitFile, Equals, 1024) + c.Assert(service.Options.LimitProc, Equals, 4096) + c.Assert(service.Application, NotNil) + c.Assert(service.Application.Name, Equals, "test-app") + + default: + c.Fatalf("Unknown service %s", service.Name) + } + } + + c.Assert(app.Validate(), IsNil) +} diff --git a/readme.md b/readme.md index beaac11..2eae8c7 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,238 @@ -## `init-exporter` +## `init-exporter` [![Build Status](https://travis-ci.org/funbox/init-exporter.svg?branch=master)](https://travis-ci.org/funbox/init-exporter) + +Utility for exporting services described by Procfile to init system. +Supported init systems: upstart and systemd + +* [Installation](#installation) +* [Configuration](#configuration) +* [Usage](#usage) + * [Procfile v.1](#procfile-v1) + * [Procfile v.2](#procfile-v2) +* [Exporting](#exporting) +* [Build Status](#build-status) +* [License](#license) + +### Installation + +To build the init-exporter from scratch, make sure you have a working Go 1.5+ workspace ([instructions](https://golang.org/doc/install)), then: + +```bash +go get -d github.com/funbox/init-exporter +cd $GOPATH/src/github.com/funbox/init-exporter +make all +sudo make install +``` + +### Configuration + +The export process can be configured through the config `/etc/init-exporter.conf`: + +```ini +# Default configuration for init-exporter + +[main] + + # Default run user + run-user: service + + # Default run group + run-group: service + + # Prefix used for exported units and helpers + prefix: fb- + +[paths] + + # Working dir + working-dir: /tmp + + # Path to directory with helpers + helper-dir: /var/local/init-exporter/helpers + + # Path to directory with systemd configs + systemd-dir: /etc/systemd/system + + # Path to directory with upstart configs + upstart-dir: /etc/init + +[log] + + # Enable or disable logging here + enabled: true + + # Log file directory + dir: /var/log/init-exporter + + # Path to log file + file: {log:dir}/init-exporter.log + + # Default log file permissions + perms: 0644 + + # Minimal log level (debug/info/warn/error/crit) + level: info +``` + +To give a certain user (i.e. `deployuser`) the ability to use this script, you can place the following lines into `sudoers` file: + +```bash +# Commands required for manipulating jobs +Cmnd_Alias UPSTART = /sbin/start, /sbin/stop, /sbin/restart +Cmnd_Alias SYSTEMD = /usr/bin/systemctl +Cmnd_Alias EXPORTER = /usr/local/bin/init-exporter + +... + +# Allow deploy user to manipulate jobs +deployuser ALL=(deployuser) NOPASSWD: ALL, (root) NOPASSWD: UPSTART, SYSTEMD, EXPORTER +``` + +### Usage + +`init-exporter` is able to process two versions of Procfiles. Utility automatically recognise used format. + +#### Procfile v.1 + +After init-exporter is installed and configured, you may export background jobs +from an arbitrary Procfile-like file of the following format: + +```yaml +cmdlabel1: cmd1 +cmdlabel2: cmd2 +``` + +i.e. a file `./myprocfile` containing: + +```yaml +my_tail_cmd: /usr/bin/tail -F /var/log/messages +my_another_tail_cmd: /usr/bin/tail -F /var/log/messages +``` + +For security purposes, command labels are allowed to contain only letters, digits, and underscores. + +#### Procfile v.2 + +Another format of Procfile scripts is YAML config. A configuration script may +look like this: + +```yaml +version: 2 +start_on_runlevel: 3 +stop_on_runlevel: 3 +env: + RAILS_ENV: production + TEST: true +working_directory: /srv/projects/my_website/current +commands: + my_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + respawn: + count: 5 + interval: 10 + env: + RAILS_ENV: staging # if needs to be redefined or extended + working_directory: '/var/...' # if needs to be redefined + my_another_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + kill_timeout: 60 + respawn: false # by default respawn option is enabled + my_one_another_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + log: /var/log/messages_copy + my_multi_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + count: 2 +``` + +`start_on_runlevel` and `stop_on_runlevel` are two global options that can't be +redefined per-command. + +`working_directory` will generate the following line: + +```bash +cd 'your/working/directory' && your_command +``` + +`env` params can be redefined and extended in per-command options. Note that +you can't remove a globally defined `env` variable. +For Procfile example given earlier the generated command will look like: + +```bash +env RAILS_ENV=staging TEST=true your_command +``` + +`log` option lets you override the default log location (`/var/log/fb-my_website/my_one_another_tail_cmd.log`). + +`kill_timeout` option lets you override the default process kill timeout of 30 seconds. + +`respawn` option controls how often the job can fail. If the job restarts more +often than `count` times in `interval`, it won't be restarted anymore. + +Options `working_directory`, `env`, `log`, `respawn` can be +defined both as global and as per-command options. + +### Exporting + +To export a Procfile you should run + +```bash +sudo upstart-export -p ./myprocfile -f format myapp +``` +Where `myapp` is the application name. This name only affects the names of generated files. For security purposes, app name is also allowed to contain only letters, digits and underscores. + +Format is name of init system `(upstart | systemd)`. + +Assuming that default options are used, the following files and folders will be generated (in case of upstart format): + +in `/etc/init/`: + +``` +fb-myapp-my_another_tail_cmd.conf +fb-myapp-my_tail_cmd.conf +fb-myapp.conf +``` + +in `/var/local/init-exporter/helpers`: + +``` +fb-myapp-my_another_tail_cmd.sh +fb-myapp-my_tail_cmd.sh +``` + +Prefix `fb-` (which can be customised through config) is added to avoid collisions with other jobs. +After this `my_tail_cmd`, for example, will be able to be started as an Upstart job: + +```bash +sudo start fb-myapp-my_tail_cmd +... +sudo stop fb-myapp-my_tail_cmd +``` + +It's stdout/stderr will be redirected to `/var/log/fb-myapp/my_tail_cmd.log`. + +To start/stop all application commands at once, you can run: + +```bash +sudo start fb-myapp +... +sudo stop fb-myapp +``` + +To remove init scripts and helpers for a particular application you can run + +```bash +sudo init-export -u -f upstart myapp +``` + +The logs are not cleared in this case. Also, all old application scripts are cleared before each export. + +### Build Status + +| Repository | Status | +|------------|--------| +| Stable | [![Build Status](https://travis-ci.org/funbox/init-exporter.svg?branch=master)](https://travis-ci.org/funbox/init-exporter) | +| Unstable | [![Build Status](https://travis-ci.org/funbox/init-exporter.svg?branch=develop)](https://travis-ci.org/funbox/init-exporter) | + +### License + +init-exporter is released under the MIT license (see [LICENSE](LICENSE)) diff --git a/testdata/procfile_v1 b/testdata/procfile_v1 new file mode 100644 index 0000000..e09077a --- /dev/null +++ b/testdata/procfile_v1 @@ -0,0 +1,3 @@ +my_tail_cmd: /usr/bin/tail -F /var/log/messages +my_another_tail_cmd: /usr/bin/tailf /var/log/messages +cmd_with_cd: cd /srv/service && env ENV_TEST=100 SOME_ENV=test /usr/bin/tail -F /var/log/messages diff --git a/testdata/procfile_v2 b/testdata/procfile_v2 new file mode 100644 index 0000000..4907745 --- /dev/null +++ b/testdata/procfile_v2 @@ -0,0 +1,46 @@ +version: 2 + +start_on_runlevel: 2 +stop_on_runlevel: 5 + +env: + RAILS_ENV: production + TEST: true + +respawn: + count: 7 + interval: 22 + +limits: + nofile: 4096 + nproc: 4096 + +working_directory: /srv/projects/my_website/current + +commands: + my_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + respawn: + count: 5 + interval: 10 + env: + RAILS_ENV: staging # if needs to be redefined or extended + working_directory: '/var/...' # if needs to be redefined + + my_another_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + limits: + nofile: 8192 + nproc: 8192 + kill_timeout: 60 + respawn: false # by default respawn option is enabled + + my_one_another_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + log: /var/log/messages_copy + + my_multi_tail_cmd: + command: /usr/bin/tail -F /var/log/messages + limits: + nofile: 1024 + count: 2