diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index e98d70dfa..8e636b8b4 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -528,7 +528,7 @@ jobs: task e2e:hooks task e2e:test - deploy-dev: + deploy-server: runs-on: ubuntu-latest if: github.repository_owner == 'semaphoreui' @@ -583,6 +583,35 @@ jobs: labels: ${{ steps.server.outputs.labels }} tags: ${{ steps.server.outputs.tags }} + deploy-runner: + runs-on: ubuntu-latest + if: github.repository_owner == 'semaphoreui' + + needs: + - integrate-boltdb + - integrate-mysql + - integrate-mariadb + - integrate-postgres + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Setup qemu + id: qemu + uses: docker/setup-qemu-action@v3 + + - name: Setup buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Hub login + uses: docker/login-action@v3 + if: github.event_name != 'pull_request' + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASS }} + - name: Runner meta id: runner uses: docker/metadata-action@v5 @@ -604,7 +633,7 @@ jobs: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/runner/Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v6 + platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: ${{ steps.runner.outputs.tags }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 1ddb6a322..6d2e02b8b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -41,8 +41,9 @@ archives: format: zip signs: - - artifacts: checksum - args: ["-u", "58A7 CC3D 8A9C A2E5 BB5C 141D 4064 23EA F814 63CA", "--pinentry-mode", "loopback", "--yes", "--batch", "--output", "${signature}", "--detach-sign", "${artifact}"] + - + artifacts: checksum + args: ["-u", "A4FB 7208 48EA 79FA EC4E 9C30 0443 8136 6A5D 4731", "--pinentry-mode", "loopback", "--yes", "--batch", "--output", "${signature}", "--detach-sign", "${artifact}"] snapshot: name_template: "{{ .Timestamp }}-{{ .ShortCommit }}-SNAPSHOT" diff --git a/LICENSE b/LICENSE index 24907af08..6023bc31c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2014 Castaway Labs LLC +Copyright (c) 2021 Denis Gukov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 09056d7c7..111357f08 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Ansible Semaphore +# Semaphore UI [![](https://img.shields.io/badge/Telegram-2CA5E0?style=flat-squeare&logo=telegram&logoColor=white)](https://t.me/semaphoreui) @@ -12,26 +12,14 @@ [//]: # ([![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/fiftin)) -Ansible Semaphore is a modern UI for Ansible. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system. +Semaphore is a modern UI for Ansible, Terraform/OpenTofu, Bash and Pulumi. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system. -If your project has grown and deploying from the terminal is no longer for you then Ansible Semaphore is what you need. +If your project has grown and deploying from the terminal is no longer for you then Semaphore UI is what you need. ![responsive-ui-phone1](https://user-images.githubusercontent.com/914224/134777345-8789d9e4-ff0d-439c-b80e-ddc56b74fcee.png) ## Installation -### Full documentation -https://docs.semui.co/administration-guide/installation - -### Snap - -[![semaphore](https://snapcraft.io/semaphore/badge.svg)](https://snapcraft.io/semaphore) - -```bash -sudo snap install semaphore -sudo semaphore user add --admin --name "Your Name" --login your_login --email your-email@examaple.com --password your_password -``` - ### Docker https://hub.docker.com/r/semaphoreui/semaphore @@ -56,55 +44,15 @@ services: - /path/to/data/lib:/var/lib/semaphore # database.boltdb location (Not required if using mysql or postgres) ``` +### Other installation methods +https://docs.semui.co/administration-guide/installation + ## Demo -You can test latest version of Semaphore on https://demo.semui.co. +You can test latest version of Semaphore on https://cloud.semui.co. ## Docs Admin and user docs: https://docs.semui.co. -API description: https://semui.co/api-docs/. - -## Contributing - -If you want to write an article about Ansible or Semaphore, contact [@fiftin](https://github.com/fiftin) and we will place your article in our [Blog](https://semui.co/blog/) with link to your profile. - -PR's & UX reviews are welcome! - -Please follow the [contribution](https://github.com/ansible-semaphore/semaphore/blob/develop/CONTRIBUTING.md) guide. Any questions, please open an issue. - -[//]: # (## Release Signing) - -[//]: # () -[//]: # (All releases after 2.5.1 are signed with the gpg public key) - -[//]: # (`8CDE D132 5E96 F1D9 EABF 17D4 2C96 CF7D D27F AB82`) - -## Support - -If you like Ansible Semaphore, you can support the project development on [Ko-fi](https://ko-fi.com/fiftin). - -## License - -MIT License - -Copyright (c) 2016 Castaway Consulting LLC - -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. +API description: https://semui.co/api-docs/. \ No newline at end of file diff --git a/api/helpers/helpers.go b/api/helpers/helpers.go index bd5f95116..8fc4e9024 100644 --- a/api/helpers/helpers.go +++ b/api/helpers/helpers.go @@ -98,6 +98,11 @@ func WriteErrorStatus(w http.ResponseWriter, err string, code int) { } func WriteError(w http.ResponseWriter, err error) { + if errors.Is(err, tasks.ErrInvalidSubscription) { + WriteErrorStatus(w, "You have no subscription.", http.StatusForbidden) + return + } + if errors.Is(err, db.ErrNotFound) { w.WriteHeader(http.StatusNotFound) return diff --git a/api/projects/inventory.go b/api/projects/inventory.go index 9a913b322..8975ae4c9 100644 --- a/api/projects/inventory.go +++ b/api/projects/inventory.go @@ -84,7 +84,7 @@ func AddInventory(w http.ResponseWriter, r *http.Request) { } switch inventory.Type { - case db.InventoryStatic, db.InventoryStaticYaml, db.InventoryFile: + case db.InventoryStatic, db.InventoryStaticYaml, db.InventoryFile, db.InventoryTerraformWorkspace: break default: helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ @@ -170,6 +170,8 @@ func UpdateInventory(w http.ResponseWriter, r *http.Request) { helpers.WriteErrorStatus(w, "Invalid inventory file pathname. Must be: path/to/inventory.", http.StatusBadRequest) return } + case db.InventoryTerraformWorkspace: + break default: helpers.WriteErrorStatus(w, "unknown inventory type: "+string(inventory.Type), diff --git a/api/projects/tasks.go b/api/projects/tasks.go index d45a67c3f..b71d02916 100644 --- a/api/projects/tasks.go +++ b/api/projects/tasks.go @@ -1,8 +1,10 @@ package projects import ( + "errors" "github.com/ansible-semaphore/semaphore/api/helpers" "github.com/ansible-semaphore/semaphore/db" + "github.com/ansible-semaphore/semaphore/services/tasks" "github.com/ansible-semaphore/semaphore/util" "github.com/gorilla/context" log "github.com/sirupsen/logrus" @@ -23,7 +25,11 @@ func AddTask(w http.ResponseWriter, r *http.Request) { newTask, err := helpers.TaskPool(r).AddTask(taskObj, &user.ID, project.ID) - if err != nil { + if errors.Is(err, tasks.ErrInvalidSubscription) { + helpers.WriteErrorStatus(w, "No active subscription available.", http.StatusForbidden) + return + } else if err != nil { + util.LogErrorWithFields(err, log.Fields{"error": "Cannot write new event to database"}) w.WriteHeader(http.StatusInternalServerError) return diff --git a/api/projects/templates.go b/api/projects/templates.go index 6d0d9b1fd..71dfe2ca8 100644 --- a/api/projects/templates.go +++ b/api/projects/templates.go @@ -71,6 +71,8 @@ func AddTemplate(w http.ResponseWriter, r *http.Request) { return } + var err error + template.ProjectID = project.ID newTemplate, err := helpers.Store(r).CreateTemplate(template) @@ -79,6 +81,43 @@ func AddTemplate(w http.ResponseWriter, r *http.Request) { return } + if newTemplate.App.IsTerraform() { + var inv db.Inventory + + if newTemplate.InventoryID == nil { + inv, err = helpers.Store(r).CreateInventory(db.Inventory{ + Name: newTemplate.Name + " - default", + ProjectID: project.ID, + HolderID: &newTemplate.ID, + Type: db.InventoryTerraformWorkspace, + Inventory: "default", + }) + + if err != nil { + helpers.WriteError(w, err) + return + } + + newTemplate.InventoryID = &inv.ID + err = helpers.Store(r).UpdateTemplate(newTemplate) + + } else { + inv, err = helpers.Store(r).GetInventory(project.ID, *newTemplate.InventoryID) + if err != nil { + helpers.WriteError(w, err) + return + } + + inv.HolderID = &newTemplate.ID + err = helpers.Store(r).UpdateInventory(inv) + } + + if err != nil { + helpers.WriteError(w, err) + return + } + } + helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: project.ID, diff --git a/api/users.go b/api/users.go index 90729b09f..bb181dabb 100644 --- a/api/users.go +++ b/api/users.go @@ -1,9 +1,9 @@ package api import ( - log "github.com/sirupsen/logrus" "github.com/ansible-semaphore/semaphore/api/helpers" "github.com/ansible-semaphore/semaphore/db" + log "github.com/sirupsen/logrus" "net/http" "github.com/ansible-semaphore/semaphore/util" diff --git a/cli/cmd/root.go b/cli/cmd/root.go index ca7b448b6..b7d734df6 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -2,10 +2,6 @@ package cmd import ( "fmt" - "net/http" - "os" - "strings" - "github.com/ansible-semaphore/semaphore/api" "github.com/ansible-semaphore/semaphore/api/sockets" "github.com/ansible-semaphore/semaphore/db" @@ -17,14 +13,17 @@ import ( "github.com/gorilla/handlers" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "net/http" + "os" + "strings" ) var configPath string var rootCmd = &cobra.Command{ Use: "semaphore", - Short: "Ansible Semaphore is a beautiful web UI for Ansible", - Long: `Ansible Semaphore is a beautiful web UI for Ansible. + Short: "Semaphore UI is a beautiful web UI for Ansible", + Long: `Semaphore UI is a beautiful web UI for Ansible. Source code is available at https://github.com/ansible-semaphore/semaphore. Complete documentation is available at https://ansible-semaphore.com.`, Run: func(cmd *cobra.Command, args []string) { diff --git a/db/Inventory.go b/db/Inventory.go index 6186ff095..4d8d39767 100644 --- a/db/Inventory.go +++ b/db/Inventory.go @@ -7,7 +7,9 @@ const ( InventoryStatic InventoryType = "static" InventoryStaticYaml InventoryType = "static-yaml" // InventoryFile means that it is path to the Ansible inventory file - InventoryFile InventoryType = "file" + InventoryFile InventoryType = "file" + InventoryTerraformWorkspace InventoryType = "terraform-workspace" + InventoryTofuWorkspace InventoryType = "tofu-workspace" ) // Inventory is the model of an ansible inventory file diff --git a/db/Template.go b/db/Template.go index 031dd3187..0a67fa297 100644 --- a/db/Template.go +++ b/db/Template.go @@ -15,9 +15,17 @@ const ( type TemplateApp string const ( - TemplateAnsible = "" + TemplateAnsible TemplateApp = "" + TemplateTerraform TemplateApp = "terraform" + TemplateTofu TemplateApp = "tofu" + TemplateBash TemplateApp = "bash" + TemplatePulumi TemplateApp = "pulumi" ) +func (t TemplateApp) IsTerraform() bool { + return t == TemplateTerraform || t == TemplateTofu +} + type SurveyVarType string const ( @@ -102,7 +110,7 @@ func (tpl *Template) Validate() error { return &ValidationError{"template name can not be empty"} } - if tpl.Playbook == "" { + if !tpl.App.IsTerraform() && tpl.Playbook == "" { return &ValidationError{"template playbook can not be empty"} } diff --git a/db/sql/option.go b/db/sql/option.go index e0f38cfb6..0589f2718 100644 --- a/db/sql/option.go +++ b/db/sql/option.go @@ -24,7 +24,7 @@ func (d *SqlDb) SetOption(key string, value string) error { func (d *SqlDb) getOption(key string) (value string, err error) { q := squirrel.Select("*"). - From(db.OptionProps.TableName). + From("`"+db.OptionProps.TableName+"`"). Where("`key`=?", key) query, args, err := q.ToSql() diff --git a/db_lib/AppFactory.go b/db_lib/AppFactory.go index 958032b55..3e10ee058 100644 --- a/db_lib/AppFactory.go +++ b/db_lib/AppFactory.go @@ -18,6 +18,26 @@ func CreateApp(template db.Template, repository db.Repository, logger task_logge Logger: logger, }, } + case db.TemplateTerraform: + return &TerraformApp{ + Template: template, + Repository: repository, + Logger: logger, + Name: TerraformAppTerraform, + } + case db.TemplateTofu: + return &TerraformApp{ + Template: template, + Repository: repository, + Logger: logger, + Name: TerraformAppTofu, + } + case db.TemplateBash: + return &BashApp{ + Template: template, + Repository: repository, + Logger: logger, + } default: panic("unknown app") } diff --git a/db_lib/BashApp.go b/db_lib/BashApp.go new file mode 100644 index 000000000..afe3c9bcc --- /dev/null +++ b/db_lib/BashApp.go @@ -0,0 +1,74 @@ +package db_lib + +import ( + "fmt" + "github.com/ansible-semaphore/semaphore/db" + "github.com/ansible-semaphore/semaphore/pkg/task_logger" + "github.com/ansible-semaphore/semaphore/util" + "os" + "os/exec" + "strings" +) + +type BashApp struct { + Logger task_logger.Logger + //Playbook *AnsiblePlaybook + Template db.Template + Repository db.Repository +} + +func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd { + cmd := exec.Command(command, args...) //nolint: gas + cmd.Dir = t.GetFullPath() + + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", util.Config.TmpPath)) + cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) + + if environmentVars != nil { + cmd.Env = append(cmd.Env, args...) + } + + if environmentVars != nil { + cmd.Env = append(cmd.Env, *environmentVars...) + } + + // Remove sensitive env variables from cmd process + for _, env := range getSensitiveEnvs() { + cmd.Env = append(cmd.Env, env+"=") + } + + return cmd +} + +func (t *BashApp) runCmd(command string, args []string) error { + cmd := t.makeCmd(command, args, nil) + t.Logger.LogCmd(cmd) + return cmd.Run() +} + +func (t *BashApp) GetFullPath() (path string) { + path = t.Repository.GetFullPath(t.Template.ID) + return +} + +func (t *BashApp) SetLogger(logger task_logger.Logger) task_logger.Logger { + t.Logger = logger + return logger +} + +func (t *BashApp) InstallRequirements() error { + return nil +} + +func (t *BashApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error { + cmd := t.makeCmd("bash", args, environmentVars) + t.Logger.LogCmd(cmd) + cmd.Stdin = strings.NewReader("") + err := cmd.Start() + if err != nil { + return err + } + cb(cmd.Process) + return cmd.Wait() +} diff --git a/db_lib/PulumiApp.go b/db_lib/PulumiApp.go new file mode 100644 index 000000000..072db5777 --- /dev/null +++ b/db_lib/PulumiApp.go @@ -0,0 +1,146 @@ +package db_lib + +import ( + "fmt" + "github.com/ansible-semaphore/semaphore/db" + "github.com/ansible-semaphore/semaphore/pkg/task_logger" + "github.com/ansible-semaphore/semaphore/util" + "os" + "os/exec" + "path" + "strings" + "time" +) + +type PulumiApp struct { + Logger task_logger.Logger + Template db.Template + Repository db.Repository + reader PulumiReader +} + +type PulumiLogger struct { + logger task_logger.Logger + reader *PulumiReader +} + +func (l *PulumiLogger) Log(msg string) { + l.logger.Log(msg) +} + +func (l *PulumiLogger) Logf(format string, a ...any) { + l.logger.Logf(format, a...) +} + +type PulumiReader struct { + confirmed bool + logger *PulumiLogger +} + +func (r *PulumiReader) Read(p []byte) (n int, err error) { + if r.confirmed { + copy(p, "\n") + return 1, nil + } + + r.logger.SetStatus(task_logger.TaskWaitingConfirmation) + + for { + time.Sleep(time.Second * 3) + if r.confirmed { + break + } + } + + copy(p, "yes\n") + r.logger.SetStatus(task_logger.TaskRunningStatus) + return 4, nil +} + +func (l *PulumiLogger) LogWithTime(now time.Time, msg string) { + l.logger.LogWithTime(now, msg) +} + +func (l *PulumiLogger) LogfWithTime(now time.Time, format string, a ...any) { + l.logger.LogWithTime(now, fmt.Sprintf(format, a...)) +} + +func (l *PulumiLogger) LogCmd(cmd *exec.Cmd) { + l.logger.LogCmd(cmd) +} + +func (l *PulumiLogger) SetStatus(status task_logger.TaskStatus) { + if status == task_logger.TaskConfirmed { + l.reader.confirmed = true + } + + l.logger.SetStatus(status) +} + +func (t *PulumiApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd { + cmd := exec.Command(command, args...) //nolint: gas + cmd.Dir = t.GetFullPath() + + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", util.Config.TmpPath)) + cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) + + if environmentVars != nil { + cmd.Env = append(cmd.Env, *environmentVars...) + } + + // Remove sensitive env variables from cmd process + for _, env := range getSensitiveEnvs() { + cmd.Env = append(cmd.Env, env+"=") + } + + return cmd +} + +func (t *PulumiApp) runCmd(command string, args []string) error { + cmd := t.makeCmd(command, args, nil) + t.Logger.LogCmd(cmd) + return cmd.Run() +} + +func (t *PulumiApp) GetFullPath() string { + return path.Join(t.Repository.GetFullPath(t.Template.ID), strings.TrimPrefix(t.Template.Playbook, "/")) +} + +func (t *PulumiApp) SetLogger(logger task_logger.Logger) task_logger.Logger { + internalLogger := &PulumiLogger{ + logger: logger, + reader: &t.reader, + } + + t.reader.logger = internalLogger + t.Logger = internalLogger + return internalLogger +} + +func (t *PulumiApp) InstallRequirements() error { + + if _, ok := t.Logger.(*PulumiLogger); !ok { + t.SetLogger(t.Logger) + } + + cmd := t.makeCmd("pulumi", []string{"stack", "init"}, nil) + t.Logger.LogCmd(cmd) + err := cmd.Start() + if err != nil { + return err + } + return cmd.Wait() +} + +func (t *PulumiApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error { + cmd := t.makeCmd("pulumi", args, environmentVars) + t.Logger.LogCmd(cmd) + cmd.Stdin = &t.reader + err := cmd.Start() + if err != nil { + return err + } + cb(cmd.Process) + return cmd.Wait() +} diff --git a/db_lib/TerraformApp.go b/db_lib/TerraformApp.go new file mode 100644 index 000000000..c583a4260 --- /dev/null +++ b/db_lib/TerraformApp.go @@ -0,0 +1,154 @@ +package db_lib + +import ( + "fmt" + "github.com/ansible-semaphore/semaphore/db" + "github.com/ansible-semaphore/semaphore/pkg/task_logger" + "github.com/ansible-semaphore/semaphore/util" + "os" + "os/exec" + "path" + "strings" + "time" +) + +type TerraformAppName string + +const ( + TerraformAppTerraform TerraformAppName = "terraform" + TerraformAppTofu TerraformAppName = "tofu" +) + +type TerraformApp struct { + Logger task_logger.Logger + Template db.Template + Repository db.Repository + reader terraformReader + Name TerraformAppName +} + +type terraformLogger struct { + logger task_logger.Logger + reader *terraformReader +} + +func (l *terraformLogger) Log(msg string) { + l.logger.Log(msg) +} + +func (l *terraformLogger) Logf(format string, a ...any) { + l.logger.Logf(format, a...) +} + +type terraformReader struct { + confirmed bool + logger *terraformLogger +} + +func (r *terraformReader) Read(p []byte) (n int, err error) { + if r.confirmed { + copy(p, "\n") + return 1, nil + } + + r.logger.SetStatus(task_logger.TaskWaitingConfirmation) + + for { + time.Sleep(time.Second * 3) + if r.confirmed { + break + } + } + + copy(p, "yes\n") + r.logger.SetStatus(task_logger.TaskRunningStatus) + return 4, nil +} + +func (l *terraformLogger) LogWithTime(now time.Time, msg string) { + l.logger.LogWithTime(now, msg) +} + +func (l *terraformLogger) LogfWithTime(now time.Time, format string, a ...any) { + l.logger.LogWithTime(now, fmt.Sprintf(format, a...)) +} + +func (l *terraformLogger) LogCmd(cmd *exec.Cmd) { + l.logger.LogCmd(cmd) +} + +func (l *terraformLogger) SetStatus(status task_logger.TaskStatus) { + if status == task_logger.TaskConfirmed { + l.reader.confirmed = true + } + + l.logger.SetStatus(status) +} + +func (t *TerraformApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd { + cmd := exec.Command(command, args...) //nolint: gas + cmd.Dir = t.GetFullPath() + + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", util.Config.TmpPath)) + cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) + + if environmentVars != nil { + cmd.Env = append(cmd.Env, *environmentVars...) + } + + // Remove sensitive env variables from cmd process + for _, env := range getSensitiveEnvs() { + cmd.Env = append(cmd.Env, env+"=") + } + + return cmd +} + +func (t *TerraformApp) runCmd(command string, args []string) error { + cmd := t.makeCmd(command, args, nil) + t.Logger.LogCmd(cmd) + return cmd.Run() +} + +func (t *TerraformApp) GetFullPath() string { + return path.Join(t.Repository.GetFullPath(t.Template.ID), strings.TrimPrefix(t.Template.Playbook, "/")) +} + +func (t *TerraformApp) SetLogger(logger task_logger.Logger) task_logger.Logger { + internalLogger := &terraformLogger{ + logger: logger, + reader: &t.reader, + } + + t.reader.logger = internalLogger + t.Logger = internalLogger + return internalLogger +} + +func (t *TerraformApp) InstallRequirements() error { + + if _, ok := t.Logger.(*terraformLogger); !ok { + t.SetLogger(t.Logger) + } + + cmd := t.makeCmd(string(t.Name), []string{"init"}, nil) + t.Logger.LogCmd(cmd) + err := cmd.Start() + if err != nil { + return err + } + return cmd.Wait() +} + +func (t *TerraformApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error { + cmd := t.makeCmd(string(t.Name), args, environmentVars) + t.Logger.LogCmd(cmd) + cmd.Stdin = &t.reader + err := cmd.Start() + if err != nil { + return err + } + cb(cmd.Process) + return cmd.Wait() +} diff --git a/deployment/docker/runner/Dockerfile b/deployment/docker/runner/Dockerfile index 8023f9aae..a98333e33 100644 --- a/deployment/docker/runner/Dockerfile +++ b/deployment/docker/runner/Dockerfile @@ -23,10 +23,34 @@ RUN --mount=type=cache,target=/go/pkg \ task deps && \ task build GOOS=${TARGETOS} GOARCH=${TARGETARCH} + +ENV OPENTOFU_VERSION="1.7.0" +#ENV TERRAFORM_VERSION="1.8.2" +#ENV PULUMI_VERSION="3.116.1" + +RUN wget https://github.com/opentofu/opentofu/releases/download/v${OPENTOFU_VERSION}/tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.tar.gz && \ + tar xf tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.tar.gz -C /tmp && \ + rm tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.tar.gz + +#RUN curl -O https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip && \ +# unzip terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip -d /tmp && \ +# rm terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip +# +#RUN if [ "$TARGETARCH" = "amd64" ]; then \ +# export PULUMI_ARCH="x64"; \ +# else \ +# export PULUMI_ARCH="${TARGETARCH}"; \ +# fi && \ +# wget -O pulumi.tar.gz https://github.com/pulumi/pulumi/releases/download/v${PULUMI_VERSION}/pulumi-v${PULUMI_VERSION}-linux-${PULUMI_ARCH}.tar.gz +#RUN tar xf pulumi.tar.gz --strip-components=1 -C /usr/local/bin +#RUN rm pulumi.tar.gz + FROM alpine:3.19 +ARG TARGETARCH="amd64" + RUN apk add --no-cache -U \ - bash curl git gnupg mysql-client openssh-client-default python3 python3-dev py3-pip rsync sshpass tar tini tzdata unzip wget zip build-base openssl-dev libffi-dev cargo && \ + bash curl git gnupg mysql-client openssh-client-default python3 python3-dev py3-pip rsync sshpass tar tini tzdata unzip wget zip && \ rm -rf /var/cache/apk/* && \ adduser -D -u 1001 -G root semaphore && \ mkdir -p /tmp/semaphore && \ @@ -42,6 +66,7 @@ RUN apk add --no-cache -U \ COPY --chown=1001:0 ./deployment/docker/runner/ansible.cfg /tmp/semaphore/ansible.cfg COPY --from=builder /go/src/semaphore/deployment/docker/runner/runner-wrapper /usr/local/bin/ COPY --from=builder /go/src/semaphore/bin/semaphore /usr/local/bin/ +COPY --from=builder /tmp/tofu /usr/local/bin/ RUN chown -R semaphore:0 /usr/local/bin/runner-wrapper && \ chmod +x /usr/local/bin/runner-wrapper && \ @@ -49,16 +74,25 @@ RUN chown -R semaphore:0 /usr/local/bin/runner-wrapper && \ chmod +x /usr/local/bin/semaphore WORKDIR /home/semaphore -USER 1001 # renovate: datasource=pypi depName=ansible ENV ANSIBLE_VERSION 9.4.0 +ARG ANSIBLE_VENV_PATH=/opt/semaphore/apps/ansible/${ANSIBLE_VERSION}/venv + +RUN apk add --no-cache -U python3-dev build-base openssl-dev libffi-dev cargo && \ + mkdir -p ${ANSIBLE_VENV_PATH} && \ + python3 -m venv ${ANSIBLE_VENV_PATH} --system-site-packages && \ + source ${ANSIBLE_VENV_PATH}/bin/activate && \ + pip3 install --upgrade pip ansible==${ANSIBLE_VERSION} boto3 botocore requests && \ + apk del python3-dev build-base openssl-dev libffi-dev cargo && \ + rm -rf /var/cache/apk/* && \ + find ${ANSIBLE_VENV_PATH} -iname __pycache__ | xargs rm -rf && \ + chown -R semaphore:0 /opt/semaphore + +USER 1001 -RUN mkdir /opt/semaphore/venv && \ - python3 -m venv /opt/semaphore/venv --system-site-packages && \ - source /opt/semaphore/venv/bin/activate && \ - pip3 install --upgrade pip ansible==${ANSIBLE_VERSION} boto3 botocore requests && \ - find /opt/semaphore/venv -iname __pycache__ | xargs rm -rf +ENV VIRTUAL_ENV="$ANSIBLE_VENV_PATH" +ENV PATH="$ANSIBLE_VENV_PATH/bin:$PATH" # Preventing ansible zombie processes. Tini kills zombies. ENTRYPOINT ["/sbin/tini", "--"] diff --git a/deployment/docker/runner/runner-wrapper b/deployment/docker/runner/runner-wrapper index 45c16e3b6..cba06c706 100644 --- a/deployment/docker/runner/runner-wrapper +++ b/deployment/docker/runner/runner-wrapper @@ -6,8 +6,6 @@ export SEMAPHORE_CONFIG_PATH="${SEMAPHORE_CONFIG_PATH:-/etc/semaphore}" export SEMAPHORE_TMP_PATH="${SEMAPHORE_TMP_PATH:-/tmp/semaphore}" export ANSIBLE_CONFIG="${ANSIBLE_CONFIG:-${SEMAPHORE_TMP_PATH}/ansible.cfg}" -source /opt/semaphore/venv/bin/activate - if test -f "${SEMAPHORE_CONFIG_PATH}/packages.txt"; then echoerr "Installing additional system dependencies" apk add --no-cache --upgrade \ diff --git a/deployment/docker/server/Dockerfile b/deployment/docker/server/Dockerfile index e7a2bac38..7e18ac466 100644 --- a/deployment/docker/server/Dockerfile +++ b/deployment/docker/server/Dockerfile @@ -23,10 +23,37 @@ RUN --mount=type=cache,target=/go/pkg \ task deps && \ task build GOOS=${TARGETOS} GOARCH=${TARGETARCH} + +ENV OPENTOFU_VERSION="1.7.0" +#ENV TERRAFORM_VERSION="1.8.2" +#ENV PULUMI_VERSION="3.116.1" + +RUN wget https://github.com/opentofu/opentofu/releases/download/v${OPENTOFU_VERSION}/tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.tar.gz && \ + tar xf tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.tar.gz -C /tmp && \ + rm tofu_${OPENTOFU_VERSION}_linux_${TARGETARCH}.tar.gz + +#RUN curl -O https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip && \ +# unzip terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip -d /tmp && \ +# rm terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip +# +#RUN if [ "$TARGETARCH" = "amd64" ]; then \ +# export PULUMI_ARCH="x64"; \ +# else \ +# export PULUMI_ARCH="${TARGETARCH}"; \ +# fi && \ +# wget -O pulumi.tar.gz https://github.com/pulumi/pulumi/releases/download/v${PULUMI_VERSION}/pulumi-v${PULUMI_VERSION}-linux-${PULUMI_ARCH}.tar.gz +#RUN tar xf pulumi.tar.gz --strip-components=1 -C /usr/local/bin +#RUN rm pulumi.tar.gz + FROM alpine:3.19 +ARG TARGETARCH="amd64" +# renovate: datasource=pypi depName=ansible +ENV ANSIBLE_VERSION 9.4.0 +ARG ANSIBLE_VENV_PATH=/opt/semaphore/apps/ansible/${ANSIBLE_VERSION}/venv + RUN apk add --no-cache -U \ -bash curl git gnupg mysql-client openssh-client-default python3 python3-dev py3-pip rsync sshpass tar tini tzdata unzip wget zip build-base openssl-dev libffi-dev cargo && \ + bash curl git gnupg mysql-client openssh-client-default python3 py3-pip rsync sshpass tar tini tzdata unzip wget zip && \ rm -rf /var/cache/apk/* && \ adduser -D -u 1001 -G root semaphore && \ mkdir -p /tmp/semaphore && \ @@ -42,6 +69,7 @@ bash curl git gnupg mysql-client openssh-client-default python3 python3-dev py3- COPY --chown=1001:0 ./deployment/docker/server/ansible.cfg /tmp/semaphore/ansible.cfg COPY --from=builder /go/src/semaphore/deployment/docker/server/server-wrapper /usr/local/bin/ COPY --from=builder /go/src/semaphore/bin/semaphore /usr/local/bin/ +COPY --from=builder /tmp/tofu /usr/local/bin/ RUN chown -R semaphore:0 /usr/local/bin/server-wrapper && \ chmod +x /usr/local/bin/server-wrapper && \ @@ -49,16 +77,21 @@ RUN chown -R semaphore:0 /usr/local/bin/server-wrapper && \ chmod +x /usr/local/bin/semaphore WORKDIR /home/semaphore -USER 1001 -# renovate: datasource=pypi depName=ansible -ENV ANSIBLE_VERSION 9.4.0 +RUN apk add --no-cache -U python3-dev build-base openssl-dev libffi-dev cargo && \ + mkdir -p ${ANSIBLE_VENV_PATH} && \ + python3 -m venv ${ANSIBLE_VENV_PATH} --system-site-packages && \ + source ${ANSIBLE_VENV_PATH}/bin/activate && \ + pip3 install --upgrade pip ansible==${ANSIBLE_VERSION} boto3 botocore requests && \ + apk del python3-dev build-base openssl-dev libffi-dev cargo && \ + rm -rf /var/cache/apk/* && \ + find ${ANSIBLE_VENV_PATH} -iname __pycache__ | xargs rm -rf && \ + chown -R semaphore:0 /opt/semaphore + +USER 1001 -RUN mkdir /opt/semaphore/venv && \ - python3 -m venv /opt/semaphore/venv --system-site-packages && \ - source /opt/semaphore/venv/bin/activate && \ - pip3 install --upgrade pip ansible==${ANSIBLE_VERSION} boto3 botocore requests && \ - find /opt/semaphore/venv -iname __pycache__ | xargs rm -rf +ENV VIRTUAL_ENV="$ANSIBLE_VENV_PATH" +ENV PATH="$ANSIBLE_VENV_PATH/bin:$PATH" # Preventing ansible zombie processes. Tini kills zombies. ENTRYPOINT ["/sbin/tini", "--"] diff --git a/deployment/docker/server/goss.yaml b/deployment/docker/server/goss.yaml index de11b4f87..695389649 100644 --- a/deployment/docker/server/goss.yaml +++ b/deployment/docker/server/goss.yaml @@ -13,8 +13,8 @@ file: package: go: installed: false - libc-dev: - installed: false +# libc-dev: +# installed: false nodejs: installed: false diff --git a/deployment/docker/server/server-wrapper b/deployment/docker/server/server-wrapper index 65521d2af..5191fb924 100755 --- a/deployment/docker/server/server-wrapper +++ b/deployment/docker/server/server-wrapper @@ -178,8 +178,6 @@ EOF fi fi -source /opt/semaphore/venv/bin/activate - if test -f "${SEMAPHORE_CONFIG_PATH}/packages.txt"; then echoerr "Installing additional system dependencies" apk add --no-cache --upgrade \ diff --git a/deployment/packaging/semaphore.spec b/deployment/packaging/semaphore.spec index 58fadadd0..e3196aba0 100644 --- a/deployment/packaging/semaphore.spec +++ b/deployment/packaging/semaphore.spec @@ -5,7 +5,7 @@ Name: semaphore Version: 2.8.90 Release: 1%{?dist} -Summary: Ansible Semaphore is a modern UI for Ansible. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system. +Summary: Semaphore UI is a modern UI for Ansible, Terraform, OpenTofu, Bash and Pulumi. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system. License: MIT URL: https://github.com/ansible-semaphore/semaphore @@ -21,7 +21,7 @@ BuildRequires: systemd-rpm-macros Requires: ansible %description -Ansible Semaphore is a modern UI for Ansible. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system. +Semaphore UI is a modern UI for Ansible, Terraform, OpenTofu, Bash and Pulumi. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system. %prep %setup -q diff --git a/deployment/systemd/semaphore.service b/deployment/systemd/semaphore.service index 7a9420cbf..af79714a4 100644 --- a/deployment/systemd/semaphore.service +++ b/deployment/systemd/semaphore.service @@ -1,5 +1,5 @@ [Unit] -Description=Ansible Semaphore +Description=Semaphore UI Requires=network.target [Service] diff --git a/go.mod b/go.mod index 64b4da012..acf2c5d48 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-gorp/gorp/v3 v3.1.0 github.com/go-ldap/ldap/v3 v3.4.6 github.com/go-sql-driver/mysql v1.7.1 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-github v17.0.0+incompatible github.com/gorilla/context v1.1.2 github.com/gorilla/handlers v1.5.2 diff --git a/go.sum b/go.sum index 3994d751e..d082abb6c 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= diff --git a/services/tasks/LocalJob.go b/services/tasks/LocalJob.go index 08bff2698..9b006d79b 100644 --- a/services/tasks/LocalJob.go +++ b/services/tasks/LocalJob.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "os" + "path" "strconv" @@ -163,6 +164,28 @@ func (t *LocalJob) getEnvironmentENV() (arr []string, err error) { return } +// nolint: gocyclo +func (t *LocalJob) getBashArgs(username string, incomingVersion *string) (args []string, err error) { + extraVars, err := t.getEnvironmentExtraVars(username, incomingVersion) + + args = append(args, t.Template.Playbook) + + if err != nil { + t.Log(err.Error()) + t.Log("Could not remove command environment, if existant it will be passed to --extra-vars. This is not fatal but be aware of side effects") + return + } + + for name, value := range extraVars { + if name == "semaphore_vars" { + continue + } + args = append(args, fmt.Sprintf("%s=%s", name, value)) + } + + return +} + // nolint: gocyclo func (t *LocalJob) getTerraformArgs(username string, incomingVersion *string) (args []string, err error) { @@ -182,8 +205,11 @@ func (t *LocalJob) getTerraformArgs(username string, incomingVersion *string) (a return } - for v := range extraVars { - args = append(args, "-var", v) + for name, value := range extraVars { + if name == "semaphore_vars" { + continue + } + args = append(args, "-var", fmt.Sprintf("%s=%s", name, value)) } return @@ -344,6 +370,10 @@ func (t *LocalJob) Run(username string, incomingVersion *string) (err error) { switch t.Template.App { case db.TemplateAnsible: args, inputs, err = t.getPlaybookArgs(username, incomingVersion) + case db.TemplateTerraform, db.TemplateTofu: + args, err = t.getTerraformArgs(username, incomingVersion) + case db.TemplateBash: + args, err = t.getBashArgs(username, incomingVersion) default: panic("unknown template app") } diff --git a/services/tasks/TaskPool.go b/services/tasks/TaskPool.go index cfbd1a712..4c9ab2782 100644 --- a/services/tasks/TaskPool.go +++ b/services/tasks/TaskPool.go @@ -1,6 +1,7 @@ package tasks import ( + "errors" "fmt" "regexp" "strconv" @@ -46,6 +47,8 @@ type TaskPool struct { resourceLocker chan *resourceLock } +var ErrInvalidSubscription = errors.New("has no active subscription") + func (p *TaskPool) GetNumberOfRunningTasksOfRunner(runnerID int) (res int) { for _, task := range p.runningTasks { if task.RunnerID == runnerID { diff --git a/services/tasks/TaskRunner.go b/services/tasks/TaskRunner.go index bcdf2fd9d..7c7c915c5 100644 --- a/services/tasks/TaskRunner.go +++ b/services/tasks/TaskRunner.go @@ -2,6 +2,7 @@ package tasks import ( "encoding/json" + "errors" "os" "strconv" "strings" @@ -178,7 +179,7 @@ func (t *TaskRunner) run() { } func (t *TaskRunner) prepareError(err error, errMsg string) error { - if err == db.ErrNotFound { + if errors.Is(err, db.ErrNotFound) { t.Log(errMsg) return err } diff --git a/util/config.go b/util/config.go index 01465c048..14191395a 100644 --- a/util/config.go +++ b/util/config.go @@ -143,6 +143,17 @@ type RunnerSettings struct { MaxParallelTasks int `json:"max_parallel_tasks" default:"1" env:"SEMAPHORE_RUNNER_MAX_PARALLEL_TASKS"` } +type AppVersion struct { + Semver string `json:"semver"` + DownloadURL string `json:"download_url"` + Path string `json:"path"` +} + +type AppConfig struct { + Name string `json:"name"` + Versions []AppVersion `json:"versions"` +} + // ConfigType mapping between Config and the json file that sets it type ConfigType struct { MySQL DbConfig `json:"mysql"` @@ -227,6 +238,8 @@ type ConfigType struct { Runner RunnerSettings `json:"runner"` GlobalIntegrationAlias string `json:"global_integration_alias"` + + Apps []AppConfig `json:"apps" env:"SEMAPHORE_APPS"` } // Config exposes the application configuration storage for use in the application diff --git a/web/public/index.html b/web/public/index.html index 13fddf96b..bd9c23af5 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -6,7 +6,7 @@ - Ansible Semaphore + Semaphore UI